Skip to content

fix(plugin-webhooks,runtime): a declared webhook the deployment can never deliver is reported at error and refused at the activation door - #24946

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24910-declared-webhook-capabilities
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24910-declared-webhook-capabilities

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Part of #24910 — this PR delivers the ruling's parts (b), (c) and the docs. Part (a), the os validate refusal, is left open as a fork for the seat: its honest home is packages/spec, a hard stop for this dispatch (see "Part (a): the fork" below). Merging this PR must not close the card.

Clause-②: no

What changes

The webhook auto-enqueuer reads record events only from the realtime service. A stack with requires: ['webhooks'] and without 'realtime' therefore never delivers a declared webhook, while every door said yes.

  • (c) Boot report, @objectstack/plugin-webhooks. The plugin decides at kernel:ready whether the auto-enqueuer can run, from three prerequisites: the engine, realtime and messaging. When it cannot and the stack declares at least one webhook, the boot logs one error in place of the old warn. The line names each declared webhook and says none of them will be delivered while record writes keep succeeding. It names the missing service and the fix ("add 'realtime' to requires, then restart"). A stack that declares no webhook keeps the old warn. autoEnqueue: false (the host's own choice) logs nothing, as before.
  • (b) Activation door, @objectstack/runtime. The declared-webhook reader (the declaredWebhooks service) is now bound already knowing that verdict: dispatchUnavailable() returns the same sentence, or null when the enqueuer runs. POST /api/v1/security/_activation/webhook/NAME asks the reader before switching ON. On a deployment that delivers no declared webhook it answers 503 with error.code: 'SERVICE_UNAVAILABLE' and writes nothing. That is the shape the door already uses when it cannot consult a reader. A reader that cannot say whether it dispatches is refused the same way.
    • Switching OFF is still written.
    • An undeclared name is still 404; the declaration is confirmed first.
    • The authority gates still answer first.
  • Docs. content/docs/automation/webhooks.mdx §3.0 names the two tokens a declared webhook needs ('webhooks', 'realtime') and what each one's absence looks like.

Files: packages/plugins/plugin-webhooks/src/{webhook-outbox-plugin,declared-webhooks}.ts, new auto-enqueuer-disabled-report.test.ts; packages/runtime/src/domains/catalog-activation.ts and its catalog-activation-door.test.ts; the doc; .changeset/24910-declared-webhook-undeliverable.md (patch for both packages).

Cross-lane: the seat's declaration on #22648, comment 6295927555, named capability-preflight.ts (with its validate / serve call sites) and catalog-activation.ts. This PR touches only catalog-activation.ts and its test. capability-preflight.ts, validate.ts and serve.ts are untouched, because of the fork below.

Premises, measured on origin/main 71696121a6

  • P1 holds. The AutoEnqueuer's only event source is IRealtimeService.subscribe (auto-enqueuer.ts start(): three subscriptions, nothing else). webhook-outbox-plugin.ts's old gate disabled it without engine, realtime or messaging. messaging IS on the always-on slate (PLATFORM_ALWAYS_ON_CAPABILITIES, packages/spec/src/kernel/platform-capabilities.ts), which is why the card's step 6 read hasMessaging: true. Under --preset minimal it is not mounted, and the plugin's dependencies on messaging then refuses the boot in the kernel's resolver; that path is not measured.
  • P2 holds. os validate loads the stack (config.webhooks, config.requires and each packages[] body) without booting, and preflightDeclaredCapabilities already reads requires from both places.
  • P3, the honest home of the rule, is packages/spec. See the fork.
  • H4, measured. autoEnqueue: false has zero production callers in this tree, only tests: plugin-webhooks (3 sites) and one dogfood test. It is an option of an exported class, so this PR treats it as a supported host choice: no report, no door refusal. Its redelivery still reads the switch (redeliver-guard.ts judgeDeclared).

Part (a): the fork (needs the seat's decision)

The rule "a stack that declares webhooks names 'webhooks' and 'realtime' in requires" belongs to a class that already exists: the declared-capability refusals of defineStack in packages/spec/src/stack.zod.ts. Its two members are validateHierarchyScopeCapability (STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED) and validateTriggerCapability (STACK_TRIGGER_CAPABILITY_REQUIRED). The trigger rule states the same token-needs-token fact in spec text ("triggers installs the trigger INTO the automation service … ⛔ triggers never IMPLIES automation"), and its docblock calls itself "an ENFORCED capability class, exactly like the hierarchy scopes above". Every door that loads a stack runs those refusals: os validate, os build, os serve / os dev, @objectstack/verify's bootStack, and cloud's runtime. So "serve reads the same rule" holds without a second call site. The ruling's hard stop says: if the honest home is packages/spec, report a fork. ⛔ No token is implied.

Option Business need (measured) Long-term Prevents AI mistakes Startup scope
A. A third defineStack refusal in spec, beside the trigger rule (domain:spec) Covers every door, including the app's own tests through bootStack. Fixture triage: packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts declares a webhook with no requires and deliberately boots without realtime. Six other files in lint and spec spell webhooks: [ (five tests and conversions/registry.ts; some are a connector's retired nested key, not a stack's), to scan. One rule in the contract and one class in one home. The earliest loud refusal: at defineStack, even inside a test. No new gate; one new ADR-0112 code. defineStack's accept set narrows (Clause-② narrowing, minor). Cloud's resolver for webhooks is NOT MEASURED (the trigger rule measured cloud's resolveCapabilityDependencies); measure it first.
B. A rule in the CLI preflight (capability-preflight.ts, domain:cli) Covers os validate / os build, and os serve after a new call site. Misses bootStack, so an app's tests stay green on the inert stack, and misses cloud's runtime. A second home for one class: two members in spec, one in the CLI. The preflight's charter is provider resolution ("Not a registry rule: it reads node_modules, not the stack", validate.ts step 3b). Loud at the CLI doors; silent in-process. No spec lane, no error code.
C. plugin-webhooks declares the realtime plugin in dependencies Boot-time "Missing dependency" only, invisible to os validate. It refuses a webhooks stack with no declared webhook and an autoEnqueue: false host, which H4 rules out. Rejected.

Recommendation: A. It joins the existing class in the one place every door reads, and its extra cost (one cloud measurement and one error code) is smaller than giving one class two homes. Under A and under B alike, a host that wires WebhookOutboxPlugin through plugins[] without naming the tokens is refused: the token is the declaration, as for triggers. H4 measured no production host of that shape.

Tests (head 02d22e1eec)

  • New auto-enqueuer-disabled-report.test.ts. A real LiteKernel boot runs the plugin's real kernel:ready. It uses a real ObjectQL over a real in-memory SqlDriver, the webhook from defineWebhook through the engine's real registerApp decomposition, and messaging's real MessagingService. Only realtime is a stand-in.
    1. With no realtime: one error naming the webhook and the fix, no disabled warn, and dispatchUnavailable() names 'realtime'. An insert plus an update enqueue nothing.
    2. CONTROL, with realtime: no error, null, and the same writes enqueue two deliveries (declared:my_app_note_changed, created then updated).
    3. With no declared webhook: the old warn, never the error.
    4. With autoEnqueue: false: nothing reported, and null.
  • catalog-activation-door.test.ts adds 7 cases:
    • ON is refused 503 SERVICE_UNAVAILABLE naming the webhook and the reader's reason, with nothing written. An empty body (the default, ON) is refused the same way.
    • CONTROL: OFF is written.
    • An undeclared name is still 404, and a walled tenant admin is still 403 first.
    • A reader without dispatchUnavailable gets 503 on ON and is written on OFF. A reader that throws gets 503.
  • pnpm --filter @objectstack/plugin-webhooks exec vitest run --maxWorkers=2: 23 files, 231 tests passed. typecheck exit 0.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2: 435 files, 7609 passed, 19 skipped. typecheck exit 0.
  • Ablation, one leg per changed behaviour. Each leg went through scripts/ablation-replace.mjs (WRAP mode, anchor x1 to x0, blob moved), and each restore was proven by blob equality with HEAD and an empty git diff HEAD. The subjects resolve through relative src imports, so no dist was involved.
    • Leg A: the error branch is never taken. Leg 1 goes red ("expected [] to have a length of 1").
    • Leg B: the reader is born without the gap. Legs 1 and 3 go red. The first attempt at B was a no-op: its replacement text already occurred inside the anchor, the tool refused and restored, and it was re-run with a distinct replacement.
    • Leg C: the door never asks the reader. 4 door cases go red (200 where 503 was expected).

Local gates (head 02d22e1eec)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths. The change set is the 7 files since the merge base 71696121a6, and the tool printed 100 commands. Each ran once, sequentially, with its exit captured before any pipe.
    • First pass: 97 exited 0, and 3 exited 3 (PREREQUISITE NOT MET): check:skill-examples (client SDK not built), check:dual-build-cjs-loads and check:i18n (dists not built).
    • After a full turbo run build under the verify lock (76/76 tasks), all three re-ran and exited 0. The working tree was still clean after the build.
  • node scripts/pm/dispatch-gates.mjs --ran answered "100 derived famil(ies) accounted for — 100 run, 0 NOT-MEASURED", exit 0.
  • Repo-wide scans (pnpm lint first among them) and the families only CI runs are left to this PR's checks.

Acceptance notes

  • Authored sys_webhook rows on the same composition. A stack with 'webhooks' and without 'realtime' delivers no authored row either. With no declared webhook that stays a warn, as the ruling scoped (c). Known from code reading only; it is not measured through a public door here.
  • The switch's other effect. On a deployment without realtime, switching a declared webhook ON would also have let its earlier delivery rows be redelivered (redeliver-guard.ts reads the switch). The door now refuses that ON too; adding 'realtime' is the fix.
  • Clause-②. This PR copies the claim's Clause-②: no. The activation door's change is a REST accept-set narrowing on such a deployment (200 to 503). By the precedent of .changeset/24813-adopt-orphans-readonly-package.md, that may read Clause-②: no (narrowing), with a minor runtime bump, a BREAKING note and an ADR-0087 marker in the changeset. That is the seat's call.
  • The card's own path (a published 18.0.0 scaffold outside the workspace) is NOT MEASURED here. The defect is reproduced in-tree by test leg 1 against its control.

Seat's append (domain:services seat 1, session_011TdZ8GjixNZFLz1xJD9zzK): the dev's round-2 markdown from os-dev report 6297443354 on #24910, verbatim. The dev writes the body once and never PATCHes it. Where the sections above describe part (b), the activation door's refusal or dispatchUnavailable(), this append supersedes them. Parts (a) and (b) go to the maintainer on #24910.

Round 2 (head 9e0ec86422): the boot report and the docs only

The seat's review ruled that the activation door's 200 to 503 change is a REST accept-set narrowing, which is protocol class. That goes to the maintainer together with part (a), so this PR now carries part (c) and the docs. The sections above that describe (b), the activation door's refusal and dispatchUnavailable(), no longer describe this diff.

  • Removed:
    • The activation door's refusal. packages/runtime/src/domains/catalog-activation.ts and catalog-activation-door.test.ts are back to main's bytes, so the PR's packages/runtime diff is empty.
    • The declared-webhook reader's dispatchUnavailable() verdict, together with its DeclaredWebhookDispatch interface and constructor parameter. The door was its only consumer. The boot report never read it: it builds its sentence from the wiring it resolves itself. bootWebhookTable and the kernel:ready hook are byte-identical to main again.
  • Kept, as built: ONE error at boot when the auto-enqueuer cannot run and the stack declares webhooks. It names each declared webhook, the consequence and the fix. The old warn stays when no webhook is declared, and autoEnqueue: false stays silent. The only addition to declared-webhooks.ts is readDeclaredWebhookNames(), which the report needs to name the webhooks.
  • Docs: webhooks.mdx §3.0 names 'webhooks' + 'realtime'. It claims no refusal by the activation door or by os validate; it says only that switching a declared webhook on delivers nothing while 'realtime' is missing.
  • Changeset: @objectstack/plugin-webhooks patch only, Clause-②: no. "Unchanged" names os validate / os build and the activation door.
  • Merged origin/main at feb09db14a; it touched only packages/cli, with no overlap.

Files now: .changeset/24910-declared-webhook-undeliverable.md, content/docs/automation/webhooks.mdx, packages/plugins/plugin-webhooks/src/{auto-enqueuer-disabled-report.test,declared-webhooks,webhook-outbox-plugin}.ts.

Tests on 9e0ec86422:

  • pnpm --filter @objectstack/plugin-webhooks exec vitest run --maxWorkers=2: 23 files, 231 tests passed. typecheck exit 0.
  • The four-case real-kernel pin: no realtime gives 1 error and 0 enqueued; the CONTROL with realtime gives 2 deliveries; no declared webhook gives the warn only; autoEnqueue: false is silent.
  • Ablation leg A, re-run on this head: the error branch is never taken, and the run reads "1 failed | 3 passed" ("expected [] to have a length of 1"). The restore was proven by blob equality with HEAD and an empty git diff HEAD.

Gates on 9e0ec86422: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack printed 100 commands (5 files vs the merge base feb09db14). All 100 ran under the verify lock and exited 0, after a full turbo run build (76/76). --ran reported "100 run, 0 NOT-MEASURED", exit 0. Round 1's head 02d22e1eec finished CI with 36 success and 6 skipped out of 42 check-runs, none red.


Generated by Claude Code

…t dispatch is reported at error and refused at the activation door

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json feb09db14a6b84dd91ca756160ef101222bdd1dc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d7d1ebcf766a393b1a6110a59479ecc518d89a33 — the merge of head 9e0ec86422df88182665719fc18acaf44495360d into base feb09db14a6b84dd91ca756160ef101222bdd1dc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d7d1ebcf766a393b1a6110a59479ecc518d89a33 && git checkout d7d1ebcf766a393b1a6110a59479ecc518d89a33
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin feb09db14a6b84dd91ca756160ef101222bdd1dc 9e0ec86422df88182665719fc18acaf44495360d && git checkout -B drift-repro feb09db14a6b84dd91ca756160ef101222bdd1dc && git merge --no-ff 9e0ec86422df88182665719fc18acaf44495360d

node scripts/docs-audit/affected-docs.mjs --json feb09db14a6b84dd91ca756160ef101222bdd1dc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…tivation door's refusal and the reader verdict go

The activation door's 200 -> 503 narrowing is the maintainer's to rule, so
packages/runtime is back to main's bytes, and the declared-webhook reader's
dispatch verdict (its only consumer was that door) is removed.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T18:15Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main ea08ce584c, not against the reports (os-dev reports 6296915123 and 6297443354 on #24910). Head under review:

9e0ec86422df88182665719fc18acaf44495360d
  1. Shape.
  2. Part (c), read in the code (webhook-outbox-plugin.ts).
    • resolveAutoEnqueueWiring decides once, at kernel:ready, among three wirings: off (autoEnqueue: false), ready (engine, realtime and messaging present) and gap.
    • On gap, reportAutoEnqueueGap reads the declared webhook names (readDeclaredWebhookNames, from the same source as the reader). When there are any, it logs ONE error naming each one, the consequence (writes succeed, nothing is enqueued) and the fix per missing service (add 'realtime' to \requires`). With none declared it logs the old warn`.
    • The ready path is unchanged. That is AGENTS.md "Degradation log levels": the system looks normal from outside while a delivery it claims never lands.
    • The round-1 door refusal and dispatchUnavailable() are removed. bootWebhookTable and the kernel:ready hook are byte-identical to main.
  3. Pins and ablation.
    • auto-enqueuer-disabled-report.test.ts runs on a real LiteKernel, a real ObjectQL over an in-memory SqlDriver, defineWebhook through engine.registerApp, and a real MessagingService. Four cases:
      • without realtime: 1 error and 0 enqueued;
      • CONTROL with realtime: 0 errors and 2 deliveries;
      • no declared webhook: the warn only;
      • autoEnqueue: false: silent.
    • Ablation leg A (the error branch never taken) turns red, expected [] to have a length of 1, re-run on this head. The restore was proven by blob equality.
  4. Docs (automation/webhooks.mdx): a declared webhook needs 'webhooks' + 'realtime' in requires. The page states the scaffold's default lacks both, and claims no refusal by os validate or the activation door.
  5. Changeset, @objectstack/plugin-webhooks patch, read sentence by sentence. "Before / After" matches 2. "Unchanged" names os validate / os build, the activation door, autoEnqueue: false and every running enqueuer.
  6. Gates.
    • At ACCEPT, every check-run on this head is success or an expected skip, among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core, Governed Surface Queue Guard and Check Documentation Links.
    • Locally, 100 of 100 derived families exited 0, after a full turbo run build; --ran reads 0 NOT MEASURED.
    • plugin-webhooks: 23 files, 231 tests passed.
  7. Seat rulings at review.
  8. Accepted deviations.
    • Part of, not Fixes.
    • One patch round, recreating the worktree.
    • Two clean merges of main.
    • The card's own path on a published 18.0.0 scaffold is NOT MEASURED; the defect is reproduced in-tree by the pin against its control.
    • Model-free trailers.
  9. Out-of-scope dispositions. An organization-authored sys_webhook row is also never delivered on that composition, and stays at warn with no declared webhook. That is code reading only, not measured through a public door. Disposition: carried into A declared webhook in a scaffolded app is silently inert: os validate passes without the 'webhooks' and 'realtime' capabilities, and with 'webhooks' alone the activation answers enabled while nothing is ever enqueued (WARN only) #24910's decision.
  10. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants