Accepted
ADR 0038 — Plugin hook system
Plugins subscribe to a fixed set of lifecycle events; a misbehaving one is abandoned, never fatal
When the host reaches a hook point — a document is published, a media file is uploaded, a sign-in is attempted — it invokes every subscribed plugin through the Dynamic Worker Loader, passes an immutable payload, and waits for the response under a deadline. A plugin that throws or stalls is logged and skipped; the host degrades gracefully rather than failing the surrounding work.
The decision
The hook event vocabulary is fixed at v1; adding events is a separate decision record. Events fall into five families.
- Document lifecycle — document.published, document.unpublished, document.deleted, document.restored, document.scheduled.
- Media lifecycle — media.uploaded, media.deleted.
- Auth lifecycle — auth.signin (returns the user to sign in, or null to reject), auth.signin.success, auth.signout.
- Render lifecycle — render.block, fired when the renderer meets a plugin-contributed block and returns the rendered HTML; render.field, fired for a plugin-contributed field type.
- Import and export — import.batch (transform a batch of documents), import.complete, export.complete.
Injecting script into the rendered page head is deliberately excluded from v1: a sanctioned-but-untrusted output surface on the public render path needs its own sanitiser decision. Plugins that need it instead own a setting that a cooperating theme reads in its head fragment.
Registration
A plugin declares its subscriptions in the manifest with a hook name and a priority. The host derives the handler method name from the hook name: the last dotted segment is the verb and the preceding segments form the noun, so document.published maps to publishedDocument and media.uploaded maps to uploadedMedia. The derivation lives in hookHandlerName in src/worker/plugins/dispatcher.ts.
export default function plugin(env) {
// env is the capability-filtered binding surface: only the bindings
// the manifest declared are present (default-deny).
return {
async publishedDocument(payload, config) {
// payload is the immutable, JSON-serialisable hook payload
// config is the plugin's editor-configured settings
},
};
}If the module does not export the expected method, the dispatch is recorded as skipped with HANDLER_NOT_EXPORTED and nothing is delivered. The plugin does not break the host.
Timeouts
The host enforces the deadline at its own boundary by racing the invocation against a timer, because a sub-isolate cannot reliably bound itself — a tight loop would otherwise hang the caller. Hooks run under a default 100 ms budget. The render.block hook runs under 200 ms because it sits on the public render path: an over-budget block renders a fallback placeholder rather than blocking the page. Callers that need a longer budget — an OAuth callout in auth.signin, a batch transform in import.batch — pass an explicit per-call timeout at the invocation site.
Ordering and merge
Subscribers run in manifest priority order — lower runs earlier — with alphabetical plugin id as a deterministic tie-break. Priority is per hook, so a plugin can run early on one event and late on another. The order comes from a host-owned plugin-subscriptions index in D1, queried once per event by the dispatcher.
Each event has exactly one merge shape, declared up front. This replaces the WordPress filter-versus-action split, where a hook silently changes meaning the day someone wants to mutate its value.
- Notification — the default for most events. Plugins observe; return values are ignored. An analytics plugin counts, a webhook plugin posts.
- First-non-null — auth.signin. The first plugin to return a user wins and later subscribers are not invoked.
- HTML — concatenates string return values from all subscribers in priority order. render.block uses a direct single-plugin path (invokeHookOnPlugin) rather than this mode; if it throws or times out, the host renders a fallback placeholder.
- List-aggregation — import.batch. Each plugin receives the original payload and returns an array; the dispatcher flattens all results in priority order. A thrown plugin is skipped.
Why
- Graceful degradation is structural. A per-subscriber try/catch means one plugin's exception cannot break a request, a publish, or a workflow step.
- Timeouts are enforced by the host, not trusted to the plugin. A per-hook timeout in the manifest was rejected because authors would set it high; the host's default must dominate.
- Ordering is deterministic, so two operators with the same plugin set see the same invocation order.
Consequences
- The hook vocabulary is load-bearing — plugins build against it, so renaming a hook is a breaking change gated by the manifest's version range.
- Every invocation writes a row to the plugin-invocations log feeding the admin Health page; the log is pruned on a retention window so it stays bounded.
- The dispatcher is the only call site that invokes plugin handlers; direct loader calls outside it are forbidden by lint and review.