Skip to content

Activity log GraphQL API - #101

Merged
dahlia merged 14 commits into
mainfrom
feat/activity-log-api
Oct 6, 2026
Merged

dahlia merged 14 commits into
mainfrom
feat/activity-log-api

Conversation

@2chanhaeng

Copy link
Copy Markdown
Member

Closes #12.

This PR records ActivityPub deliveries in both directions and exposes them through GraphQL as Instance.activityLogs and Actor.activityLogs. The logs are newest first and can be filtered by direction, status, and type. The diff adds about 12,000 lines, but only about 4,000 of those contain logic. The rest is for tests, migration snapshots, etc.

Changes

  • Storage (@drfed/models): adds the activity_logs, activity_log_actors, and activity_log_attempts tables, plus keys and key_versions for public key history. recordInbound, receiveInbound, recordOutbound, and settleOutbound write the logs. Bodies PostgreSQL cannot store are kept raw, with payload = NULL.
  • Inbound: createInboundRecorder wraps the federation HTTP surface and logs every inbox POST. DrFed never verifies a request again. The verification mechanism, result, and key version come from the spans and metrics that Fedify documents and from its key cache.
  • Outbound: deliverActivity logs one row per destination inbox. A wrapped outbox queue settles each attempt that Fedify's worker makes, including retries and abandonment.
  • GraphQL: ActivityLog exposes the raw request, the response, the verification details, and the attempts. Key.versions is now a connection. Only local instance members and administrators can read logs, including through Relay node IDs.
  • Wiring: createFederation returns a TrackedFederation. drfed-server routes requests through the recorder. Fedify is upgraded to 2.4.0.

AI disclosure

Claude Code (claude-opus-5-5, claude-fable-5-1) and Codex (gpt-6-astra) helped write this change, and every AI-assisted commit has Assisted-by trailers. The contributor reviewed the changes and verified them with mise run build, mise run check, and mise run test.

Implement the activity-log API from plans/12: immutable public-key versions,
delivery observations, inbox recording, synchronous outbound hooks, and
member-authorized Relay connections. Preserve original inbound JSON and
keep key history independent of the federation cache.

The contributor requested implementation of the supplied plan and an
independent Claude Fable 5 review outside the sandbox. Codex generated the
schema, migration, recording helpers, GraphQL API, integration, tests, and
documentation. Implementation checks led to nullable GraphQL JSON payloads,
request-local verification-key snapshots, and an empty key dispatcher until
local signing keys are implemented. Fable identified orphan key observations
on unclaimed hosts; Codex moved the instance gate before verification and
added regression coverage. Fable's second review found no issues.

Agent validation: mise run build, mise run check, and all 240 tests passed.
A running development server accepted a signed inbox request (202), rejected
an unsigned request (401), and persisted the expected logs and key version.
Tarball installation with locally installed external dependencies verified
new exports, bundled migrations, and drfed-server --help. Human review and
verification are not asserted by these agent-run checks.

Assisted-by: Codex:gpt-6-astra
Assisted-by: Claude Code:claude-fable-5
Extend activity_logs with the verification mechanism and result, the
raw request body, headers, and URL, the response body, every type, the
recipient IRIs, and the completion time.  Add activity_log_actors,
which links an inbound log to the actors it addresses, and
activity_log_attempts, which keeps every outbound delivery attempt.
Add the acknowledged and abandoned statuses and the unattempted and
unobserved verification results.

A payload PostgreSQL cannot store as jsonb is kept as NULL while the
raw body stays, and NUL in stored errors and response bodies becomes
U+FFFD.  receiveInbound() marks a log received once a queue worker
handles its activity.

#12

Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.

Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
tracking.ts keeps, per request, the public key cache entries, spans,
measurements, and responses Fedify produces.  telemetry.ts supplies the
tracer and meter providers that collect Fedify's documented spans,
events, and metrics, and reads response status codes from undici's
diagnostics_channel.

describe.ts derives log columns from an activity, keeping only IRIs
that parse as URLs and no NUL.  addressing.ts finds the local actors an
activity addresses, including members of stored collections.
instanceUrl() composes the canonical URL of a path on an instance.

#12

Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.

Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
Wrap the outbox queue so that every attempt of Fedify's worker settles
its log and adds an attempt.  Retries and exhaustion come from the
activitypub.outbox.activity metric, and the queue reports nativeRetrial
as false so that every retry goes through Fedify.  Log IDs travel on
queue messages, so repeated deliveries settle their own rows.

deliverActivity() strips bto and bcc, skips recipients without an id as
Fedify does, and counts a delivery as sent only on an
activitypub.activity.sent event; one Fedify never sent or enqueued
settles as permanently_failed.  The status code follows redirects from
the inbox to the final response.  The tests run Fedify's own delivery
against a local inbox server.

#12

Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.

Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
The recorder no longer verifies requests itself.  Verdicts come from
Fedify's verification spans and the
activitypub.signature.verification.duration metric, and the key from
the cache entries of that verification's own key fetches, so the
stored key version is the one Fedify used.  A key that could not be
fetched is recorded as key_fetch_error with the reason, and a verified
proof whose actor Fedify refused as verified but rejected.

A log's created time is when the request arrived.  Requests whose
handling throws are logged before the error is rethrown.

#12

Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.

Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
ActivityLog exposes the verification mechanism and result, the raw
request, the response body, the completion time, and its attempts.
Actor.activityLogs goes through activity_log_actors, and Key.versions
becomes a connection.  Restore the import order of schema.ts.

#12

Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.

Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
createFederation() hands Fedify the tracking KV store and the tracer
and meter providers, wraps the outbox queue, and returns a
TrackedFederation, the only federation createInboundRecorder()
accepts.  Inbox listeners call markHandled(), which tells a received
activity from an acknowledged duplicate.  The recorder takes the root
origin instead of the KV store.  Document the contract.

#12

Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.

Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
Fedify 2.4.0 changes several behaviors DrFed relies on:

 -  Private addresses are refused for outbound delivery too, redirects
    included.  drfed-server allows them, so that it can deliver to apps
    under development on a local network, and so do the tests that
    deliver to a local inbox.  createFederation() leaves the option to
    its callers, since Fedify refuses it together with the loader
    factories the inbound tests pass.
 -  KvKeyCache keeps each public key under the generation segment "2"
    and with a 30-day TTL.  createKeyCache() and trackedKey() follow
    that layout, so the key a verification used is recorded again.
 -  Object tombstones are served with HTTP 410.
 -  Fedify fetches the actor on every inbox request to check that it
    owns the key, so the cache test counts only fetches of the key.
 -  Fedify follows every redirect itself, signed or not, reading a
    Location outside ASCII as Latin-1.

The contributor bumped the Fedify catalog entries, allowed private
addresses in drfed-server and in createFederation(), and asked Claude
Code to add the option wherever it is needed and to fix the tests the
upgrade broke.  Claude Code traced each failure to the Fedify change
behind it, moved the option from createFederation() to the callers
that need it, and wrote the fixes above.  mise run check and mise run
test pass.

Assisted-by: Claude Code:claude-opus-5-5
@2chanhaeng
2chanhaeng force-pushed the feat/activity-log-api branch from ccd5d5e to f8bab57 Compare October 1, 2026 07:05

@dahlia dahlia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scoped key-cache handling needs a fix: a successfully verified inbound activity can currently be recorded without retaining the verification key.

I also left comments on addressing provenance, GraphQL edge metadata, and attempt pagination. I would like us to agree on a common model for locally created and remotely received activities, with delivery history represented separately.

The database naming convention and additional integrity checks can be discussed in follow-up issues; they do not need to block the MVP.

Comment thread packages/graphql/src/activity-delivery/keycache.ts
Comment thread packages/models/src/activity-log.ts Outdated
Comment thread packages/graphql/src/activity-log/entry.ts Outdated
Comment thread packages/graphql/src/activity-log/entry.ts Outdated
Comment thread packages/models/src/schema.ts
Comment thread packages/models/src/schema.ts
Comment thread packages/graphql/src/activity-log/entry.ts Outdated
…rver

Restore isolated GraphQL test databases from a migrated PGlite snapshot to avoid repeated initialization and migrations. Run tests after the server build without requiring the unused web build.

Codex was asked to apply snapshot reuse and compare test times. It implemented the harness change and measured mise run test at 59.77s before and 39.27s after. All 286 tests and mise run check passed.

The user modified the `test` task to depend only on `build:server` so that the frontend would not be built when the `test` task is run directly.

Assisted-by: Codex:gpt-6-astra
Fedify 2.4 caches a key at a compatible identifier, such as
https://remote.example/.well-known/apgateway/did:key:.../actor#key,
only under ["__compatible", scope, keyIri], wrapped as
{ key, expires }, and never under ["2", keyIri].  trackedKey() read
only the latter, so an Object Integrity Proof verified with such a key
was logged as verified with no verification key nor key version.

trackedKey() now also reads the scoped entry of the purpose the
verification mechanism looked the key up for, chosen by the same rule
as Fedify's getCompatibleKeyScope(): multikey for Object Integrity
Proofs, httpSignature for HTTP signatures, cryptographicKey for Linked
Data Signatures.  It unwraps the key only from a value Fedify's
isCompatibleKeyEntry() would accept, and takes a null key as none.

createKeyCache() gains compatibleKeyScope() with the same wire format,
so the serialization test covers scoped entries too.  A regression
test sends such a proof twice, fetched and then cached, and checks
that both logs keep the same key version; it failed before this change
with a null verificationKeyId.

#101 (comment)

The contributor asked Claude Code to apply the review following a plan
they had reviewed.  Claude Code reproduced the reported symptom with
the regression test first, then wrote the fix and the tests.  mise run
check and mise run test pass.

Assisted-by: Claude Code:claude-opus-5-5
activity_log_actors kept a single via_collection_iri per log and actor,
so an actor reached through two collections kept whichever the database
returned last, and one also addressed directly lost the collections.
It now records addressed_directly, and a row in the new
activity_log_actor_collections table for each collection, referencing
its actor row with ON DELETE CASCADE.  inboundActorRows() merges the
entries of an actor into one row with sorted, distinct collection IRIs,
so the result no longer depends on their order.

Each actor row also copies the created of its log, indexed as
(actor_id, created desc, log_id desc), so that an actor's logs can be
paged over the rows themselves in the order the instance's logs use.
actors.activityLogLinks relates an actor to them.

recordInbound() and recordOutbound() now refuse to relate a log to any
actor, as inbox owner, sender or addressed actor, that is not a local
actor of the log's instance, and record nothing then.  Neither foreign
key can tell, and a composite foreign key would conflict with
activity_logs.actor_id being set to null when the actor is deleted.

The migration has not been released, so it is generated again instead
of being followed by another.

#101 (comment)
#101 (comment)
#101 (comment)

The contributor asked Claude Code to apply the reviews following a
plan they had reviewed.  Claude Code wrote the schema, recording and
test changes and regenerated the migration.  mise run build, mise run
check and mise run test pass.

Assisted-by: Claude Code:claude-opus-5-5
ActivityLog.attempts was a list of every attempt a delivery ever made,
which a retry policy without a limit can make arbitrarily long, and
turning it into a connection later would break existing queries.  It is
now a Relay connection like Key.versions, oldest first, paged over the
(log_id, created, id) index.

Actor.activityLogs is now paged over the actor's activity_log_actors
rows, the way Account.instances is over its memberships, ordered by the
created they copy from their logs and their log IDs, as before.  Its
edges tell how each delivery concerns the actor: inboxOwner, sender,
addressed, addressedDirectly, and viaCollections, every addressed
collection the actor was a member of when the delivery arrived.  A
shared-inbox delivery, whose actor is null, thus still explains why it
is in the actor's feed.  The filters apply to the logs through the
relation, and the through relation actors.activityLogs is gone.

Cursors of Actor.activityLogs change from (created, id) to
(created, logId); the API has not been released and the web app does
not use it yet.

#101 (comment)
#101 (comment)

The contributor asked Claude Code to apply the reviews following a
plan they had reviewed.  Claude Code wrote the connections and the
tests, and found that Pothos cannot add a composite primary key to a
narrowed selection, so the collections of each edge select every
column.  mise run build, mise run check and mise run test pass.

Assisted-by: Claude Code:claude-opus-5-5
@2chanhaeng
2chanhaeng requested a review from dahlia October 3, 2026 07:26
Comment thread packages/graphql/src/activity-delivery/outbound.ts
@sij411 sij411 mentioned this pull request Oct 5, 2026
2 tasks done
@sij411
sij411 added this pull request to stack #106 October 6, 2026 04:38
A row of activity_logs records one observation of an activity crossing
an inbox, not the activity itself, and the review asked for a common
Activity model with the delivery history kept apart from it.  Renaming
the records later would break the queries this API publishes, so they
are renamed now, before it is released:

 -  The activity_logs, activity_log_attempts, activity_log_actors and
    activity_log_actor_collections tables become activity_deliveries,
    activity_delivery_attempts, activity_delivery_actors and
    activity_delivery_actor_collections, and their log_id columns
    become delivery_id.  Their enums, checks and indexes follow.
 -  GraphQL ActivityLog becomes ActivityDelivery, ActivityLogAttempt
    becomes ActivityDeliveryAttempt, and Instance.activityLogs and
    Actor.activityLogs become activityDeliveries, together with their
    connections, edges, filter and enums.  The Relay global IDs of
    deliveries change with the type name.
 -  The activity-log modules and the @drfed/models/activity-log and
    @drfed/graphql/activity-log subpath exports become
    activity-delivery, and the queue message key, KV key and logger
    category follow.

Functions whose names do not mention logs, such as recordInbound() and
deliverActivity(), keep their names.  A link from each delivery to its
Activity and an Activity.deliveries connection are additive, so they
are left to the issue that persists inbound activities.

The migration has not been released, so it is generated again.  Two
foreign key names derived from the longer table names exceed 63 bytes,
so drizzle-kit shortens them with a hash.

#101 (comment)
#101 (comment)
#88

The contributor agreed on the scope with the reviewer and asked Claude
Code to apply it.  Claude Code renamed the identifiers, files and
documents, regenerated the migration and compared it with the previous
one.  mise run build, mise run check and mise run test pass.

Assisted-by: Claude Code:claude-opus-5-5
@dahlia
dahlia merged commit 20ef96b into main Oct 6, 2026
13 of 15 checks passed
dahlia added a commit that referenced this pull request Oct 6, 2026
DrFed's ActivityPub dispatchers, inbox listeners, vocabulary
serialization, and activity delivery tracking lived in @drfed/graphql,
so serving ActivityPub required the GraphQL package even though it
never needs a schema.  They now live in a new @drfed/federation package
that depends on @drfed/models but not on GraphQL, following the
structure of Hackers' Pub's federation package while keeping DrFed's
fresh-builder factory.

The new package exposes four subpaths:

 -  @drfed/federation: buildFederation(db) and the default
    createFederation(db, options), unchanged in signature and behavior,
    plus createInboundRecorder(), deliverActivity(), and the
    TrackedFederation type.  buildFederation() still creates a fresh
    builder on every call and passes it to register functions in
    actor.ts, object-dispatchers.ts, collection.ts, and inbox.ts, in the
    same registration order as before.  Nothing registers on a
    module-level builder.
 -  @drfed/federation/activity-delivery: the delivery recording
    functions that @drfed/graphql/activity-delivery used to re-export.
 -  @drfed/federation/object: objectSelection, activitySelection,
    toObject(), toCreate(), and the StoredObject/StoredCreate input
    types.  GraphQL mutations keep using these to build stored JSON-LD
    documents, so stored documents and ActivityPub responses share one
    set of serialization rules.
 -  @drfed/federation/origin: the instance host rules, moved verbatim
    from packages/graphql/src/origin.ts so that server routing and
    federation lookups cannot disagree about instance hosts.

The activity delivery runtime from #101 had to move along with the
dispatchers: createFederation() wraps the KV store, tracer, meter, and
queues with it, the inbox listener calls markHandled(), and the builder
registers its permanent-failure handler.  Its nine GraphQL-free modules
moved unchanged into src/activity-delivery/; only the Pothos types in
packages/graphql/src/activity-delivery/entry.ts stay behind.

The Public addressing and served-activity predicates moved to an
internal visibility.ts shared by the Create dispatcher, outbox pages,
and outbox counter.  Registration helpers stay internal and are not
exported.

@drfed/graphql drops its ./federation and ./origin subpaths, and
./activity-delivery no longer re-exports runtime functions, without
compatibility shims, since no version has been released and every
consumer is in this repository.  Its README lists the replacement
imports.  @drfed/graphql and @drfed/drfed import from
@drfed/federation, and mise.toml's build:server task builds the new
package.  No tsconfig path alias is added for @drfed/federation:
mapping its root to source while its subpaths resolve to dist/ would
give TrackedFederation two incompatible unique-symbol brands.

The intended observable changes are the logger categories, from
["drfed", "graphql", "federation"] to ["drfed", "federation"] and from
["drfed", "graphql", "activity-delivery"] to
["drfed", "federation", "activity-delivery"].

Tests that need only a database and a federation moved to the new
package with their own temporary-database harness, a copy of the
database-only seed fixtures, and a copy of the remote inbox helper;
this includes the outbound and queue delivery tests.  The
GraphQL-dependent ones remain in @drfed/graphql, including
activitypub-integration.test.ts and the inbound delivery tests; the
test that checked collection items over both ActivityPub and GraphQL
was split between the two.  A new regression test builds federations
from two databases holding the same actor identifier and checks that
each one serves its own database's actor, which a shared builder would
break.  The new package adds DOM to its TypeScript lib so the moved
tests keep reading untyped JSON response bodies, as they did under
@drfed/graphql, which gets DOM typings transitively through
graphql-yoga.

Verified with a clean `mise run build`, `mise run check`, and
`mise run test`; with `mise run dev`; and by installing the packed
tarballs of all four server packages outside the repository, starting
the installed drfed-server on an empty PGlite directory, seeding an
instance, checking GraphQL, actor, object, Create, outbox, and
WebFinger responses, checking that an unsigned inbox POST is recorded
as an unverified inbound delivery, and type-checking a strict
TypeScript consumer of every @drfed/federation subpath.

Closes #102

AI provenance: Claude Code (Claude Opus 5.5) read the issue and the
Hackers' Pub reference code, drafted the design and refactoring plan,
implemented the move, resolved the rebase onto the activity delivery
work by moving its runtime into the new package, wrote the new tests
and documentation, and ran the build, check, test, dev-server, and
packed-installation verification.  Codex (GPT-6 Astra) reviewed the
plan over three rounds; its feedback separated the public serializer
subpath from internal dispatcher registration and shared predicates,
added the cross-database isolation test, and extended the
packed-package verification.  Codex (GPT-6 Astra) and Claude Code
(Claude Fable 5.1) then reviewed the change before and after the
rebase and reported no actionable findings, so neither review changed
the code.

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Development

Successfully merging this pull request may close these issues.

GraphQL API for activity log

3 participants