Skip to content

Add a companion module initialization gate#3541

Closed
Jiwoon-Kim wants to merge 1 commit into
Automattic:trunkfrom
Jiwoon-Kim:feature/verified-inbox-handoff
Closed

Add a companion module initialization gate#3541
Jiwoon-Kim wants to merge 1 commit into
Automattic:trunkfrom
Jiwoon-Kim:feature/verified-inbox-handoff

Conversation

@Jiwoon-Kim

@Jiwoon-Kim Jiwoon-Kim commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

This draft adds one backwards-compatible extension surface for companion plugins:

  • activitypub_module_enabled, an early gate around independently initialized runtime, REST, and admin modules.

Every module remains enabled by default, so stock behavior is unchanged. A companion can retain the official protocol surfaces it needs while replacing selected presentation, lifecycle, or domain modules.

Why

As pfefferle pointed out, a new verified-Inbox handoff is not necessary. The existing REST controllers already fire activitypub_inbox and activitypub_inbox_shared after their permission callbacks, and activitypub_skip_inbox_storage can prevent duplicate CPT persistence.

The remaining purpose of this PR is narrower: provide a supported, stable way to stop module initializers before they register callbacks. Without a gate, companion plugins must reach into concrete class names, callbacks, and exact priorities with remove_action(). That is workable today, but silently becomes ineffective when an initializer or priority changes.

For example, a companion with its own URI-keyed Activity and relationship repositories can disable runtime.handler and runtime.scheduler, retain runtime.signature, rest.server, rest.inbox, and rest.actors_inbox, then consume the existing Inbox actions. The official plugin remains responsible for request validation and transport while the companion owns domain state.

This PR no longer adds or depends on a new Inbox handoff API. The previous handoff commit has been removed from the branch.

Reference implementation

Axismundi ActivityPub Bridge 0.0.12 Alpha uses the module gate together with the existing Inbox actions:

  • shared Inbox and per-Actor Inbox both record exactly one URI-keyed Activity;
  • official type handlers remain dormant;
  • activitypub_skip_inbox_storage skips ap_inbox only after Axismundi successfully claims the Activity; and
  • unclaimed local deliveries retain the official Inbox snapshot fallback.

The surrounding alpha stack is available here:

The companion plugins and block theme provide local and remote Actor profile pages, Object and Activity views, social relationships, and federated-media presentation in the site's native frontend. Axismundi owns the URI-keyed domain and presentation layers; the official plugin remains the intended S2S transport and interoperability layer.

Staging references:

Validation

  • Added upstream unit coverage for default-on and selective module behavior.
  • Axismundi composition fixtures cover shared and per-Actor Inbox controllers, replay idempotency, successful claim storage suppression, and unclaimed snapshot fallback.
  • Bridge composition: 8/8 Inbox checks and 20/20 module-scaffold checks.
  • Activities state-machine regression: 68/68 checks.
  • Bridge Plugin Check: 0 errors.
  • Staging WebFinger returns the canonical Axismundi Actor document; signed cross-instance Follow/Accept E2E passed in both directions against mastodon.social. Like, Undo, shared-Inbox delivery, and Create delivery remain separate activity-level scenarios.

Refs #3533

@pfefferle

Copy link
Copy Markdown
Member

Why not simply unhook all inbox handlers and hook in yours?

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Fair point. The first Bridge prototype did essentially that: keep the verified
REST Inbox, prevent the official domain handlers from being initialized, hook
the existing Inbox actions, and use activitypub_skip_inbox_storage so the
official CPT is not authoritative.

I introduced the earlier handoff to avoid coupling a companion to the growing
set of type-specific handlers and to stop the remaining controller work once an
Activity had been claimed. However, with the module gate preventing
Handler::init(), those official type handlers are not registered in the first
place. The existing activitypub_inbox / activitypub_inbox_shared actions plus
the storage-skip filter may therefore already be the simpler supported
composition.

I will verify both the shared and per-Actor Inbox paths against that approach.
If they cover the handoff without duplicate calls or persistence, I will remove
the new handoff API from this PR and keep the smaller module-gate change.

Thanks for pointing that out.

@Jiwoon-Kim
Jiwoon-Kim force-pushed the feature/verified-inbox-handoff branch from 4967e3e to 0f7c67b Compare July 15, 2026 16:47
@Jiwoon-Kim Jiwoon-Kim changed the title Add module gates and a verified Inbox handoff Add a companion module initialization gate Jul 15, 2026
@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Thanks, you are right. I verified that both REST controllers fire the existing Inbox actions after their permission callbacks, independently of Handler::init().

I have now:

  • removed the proposed verified-Inbox handoff commit from this branch;
  • rewired the companion to activitypub_inbox and activitypub_inbox_shared;
  • kept the official type handlers dormant;
  • used activitypub_skip_inbox_storage only after the companion successfully records the Activity; and
  • covered both shared and per-Actor controllers, replay idempotency, and unclaimed fallback storage.

The force-pushed PR now contains only the module initialization gate. Its narrower purpose is to avoid depending on concrete initializer callbacks and exact priorities when a companion disables Handler, Scheduler, Router, or selected REST surfaces.

The revised reference implementation is Axismundi ActivityPub Bridge 0.0.12 Alpha.

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

A quick update from the staging integration: the signed S2S Follow/Accept path is now working end to end against mastodon.social in both directions.

Verified paths:

  • Mastodon Follow -> official plugin signature/request validation -> existing activitypub_inbox action -> Axismundi URI-keyed Activity/relationship store -> outbound Accept through the official queue/signing/delivery implementation -> accepted by Mastodon.
  • Axismundi Follow -> official queue/signing/delivery implementation -> Mastodon Accept -> official plugin validation/Inbox action -> Axismundi relationship reaches accepted.

The final issue found during this test was not in signature verification or the Inbox handoff. Axismundi deliberately keeps domain-ledger payloads free of JSON-LD representation concerns, but the Bridge initially submitted that context-free payload directly to transport. Mastodon accepted the HTTP request and then discarded it during asynchronous Activity processing because the ActivityStreams context was absent. The Bridge now finalizes outbound Activities through Object Projections before transport, restoring the intended ownership boundary: Activities owns domain state, Object Projections owns JSON-LD representation, and the official plugin owns protocol transport. The fix and regression coverage are in Jiwoon-Kim/axismundi@9b78ef5.

The official plugin has understandably optimized for broad WordPress and Fediverse compatibility. Axismundi's URI-keyed Actor, Object, Activity, and relationship repositories optimize for a more extensible domain model and a block-theme-native Fediverse frontend. With the module gate, those goals compose cleanly rather than duplicating protocol behavior: the official plugin remains the interoperability and S2S layer, while a companion can replace selected domain and presentation layers.

If you have time, I would really appreciate testing this reference composition or pointing out assumptions that would make it unsuitable upstream. The working staging surface is:

Scope of the current E2E evidence is specifically Follow/Accept. Like, Undo, shared-Inbox delivery, and Create delivery remain separate activity-level scenarios.

@Jiwoon-Kim
Jiwoon-Kim marked this pull request as ready for review July 16, 2026 09:41
@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

The updated companion releases used for the successful signed Follow/Accept E2E are now available:

These are the packaged versions corresponding to the staging result described above. The Bridge release includes the outbound Activity JSON-LD finalization fix; Activities includes Follow-cycle succession and Re-follow controls; Object Projections owns canonical ActivityStreams context assembly.

@pfefferle

Copy link
Copy Markdown
Member

Thanks for the quick turnaround on the inbox handoff, and for verifying both controller paths before removing it. The E2E work against mastodon.social is genuinely appreciated — that's more validation than most PRs bring.

On the module gate itself, I'm going to pass, and I want to be transparent about why rather than just closing it.

The core problem is API surface. This PR promotes ~35 internal module names (runtime.handler, rest.actors_inbox, …) into a stable public contract. Once a companion filters on runtime.handler, renaming that class, splitting a controller, or merging two initializers becomes a breaking change. Your argument against remove_action() is that it silently breaks when a callback or priority changes — that's true, but the gate has the same problem with a bigger blast radius: it's a contract over our entire internal structure instead of over the two or three seams you actually use.

There's also a support concern: a one-line filter that can switch off runtime.signature or rest.webfinger makes it very easy for a third plugin to ship a half-federated site whose breakage looks like our bug. Module-level kill switches invite that; behavior-level filters don't.

And I don't think you need the gate for your stated use case. What the Bridge actually needs is "the official type handlers must not act on activities the companion has claimed," and that's expressible today at the behavior level:

  • Handler::init() fires activitypub_register_handlers — a documented seam that runs exactly when all type handlers have attached, so a companion can unhook specific activitypub_inbox_* callbacks there without guessing at priorities.
  • activitypub_skip_inbox_storage already covers persistence, as you've confirmed.
  • The Follow cycle exposes activitypub_handled_follow for reacting to what our handler did.

If you hit a specific behavior that can't be prevented or claimed through those — say, a handler with a side effect that needs a skip filter like the storage one — that's exactly the kind of small, targeted PR I'd be glad to take. "This behavior fired when the companion had already claimed the activity" is a concrete, testable contract; "this module didn't initialize" isn't.

Same conclusion as on #3548, really: the composition keeps working precisely because the seams already exist. Let's keep growing those seams one verified behavior at a time instead of freezing the module map.

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed explanation. I agree with the behavior-level boundary and have removed the module-gate dependency from the companion.

The Bridge now:

  • keeps Handler, Scheduler, and Dispatcher initialized;
  • uses activitypub_register_handlers to unhook only the official type callbacks whose state Axismundi owns;
  • uses activitypub_register_schedulers to unhook only local post, Actor, collection, and comment publication callbacks;
  • continues to consume activitypub_inbox / activitypub_inbox_shared and skips Inbox CPT storage only after a successful claim; and
  • leaves official remote-cache maintenance and the official Dispatcher active for their own data.

The only initializer still disabled is Router, because the companion owns the overlapping /@handle and canonical Actor/Object presentation routes and Router currently has no equivalent post-registration seam. That is a narrow local compatibility choice rather than an upstream API request.

I verified the revised composition against both this fork and unmodified ActivityPub 9.0.2. Shared and per-Actor Inbox handling, unclaimed fallback storage, WebFinger, outbound signing, and Follow/Accept queueing all pass. The implementation and regression coverage are in Jiwoon-Kim/axismundi@ff959fe.

This PR is no longer needed, so I am closing it. Thank you for steering this toward the smaller contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants