Activity log GraphQL API - #101
Merged
Merged
Conversation
2chanhaeng
force-pushed
the
feat/activity-log-api
branch
from
October 1, 2026 06:20
cf6450c to
b9e1298
Compare
2chanhaeng
force-pushed
the
feat/activity-log-api
branch
from
October 1, 2026 06:33
b9e1298 to
ccd5d5e
Compare
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
force-pushed
the
feat/activity-log-api
branch
from
October 1, 2026 07:05
ccd5d5e to
f8bab57
Compare
dahlia
requested changes
Oct 1, 2026
dahlia
left a comment
Member
There was a problem hiding this comment.
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.
…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
sij411
reviewed
Oct 4, 2026
2 tasks done
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
approved these changes
Oct 6, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #12.
This PR records ActivityPub deliveries in both directions and exposes them through GraphQL as
Instance.activityLogsandActor.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
@drfed/models): adds theactivity_logs,activity_log_actors, andactivity_log_attemptstables, pluskeysandkey_versionsfor public key history.recordInbound,receiveInbound,recordOutbound, andsettleOutboundwrite the logs. Bodies PostgreSQL cannot store are kept raw, withpayload = NULL.createInboundRecorderwraps the federation HTTP surface and logs every inboxPOST. 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.deliverActivitylogs one row per destination inbox. A wrapped outbox queue settles each attempt that Fedify's worker makes, including retries and abandonment.ActivityLogexposes the raw request, the response, the verification details, and the attempts.Key.versionsis now a connection. Only local instance members and administrators can read logs, including through Relay node IDs.createFederationreturns aTrackedFederation.drfed-serverroutes 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-bytrailers. The contributor reviewed the changes and verified them withmise run build,mise run check, andmise run test.