Skip to content

fix(runtime): an exact POST /webhooks/redeliver dispatcher domain for kernels with no raw app - #22842

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22755-webhooks-redeliver-domain
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22755-webhooks-redeliver-domain

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22755
Clause-②: no

Segment 2 of the webhooks half of #22564's stage 2, under the maintainer ruling recorded verbatim as 「A + 扫类」 on #22438 (director record 6079645593, item 2). A hosted tenant kernel owns no socket, so it has no raw Hono app for plugin-webhooks to mount the redeliver door on: on that shape POST /api/v1/webhooks/redeliver answered 404 ROUTE_NOT_FOUND, even though the plugin had registered the webhooks slot whose handleRedeliver member (segment 1's contract, #22756's member) serves it. This PR gives the dispatcher that route. Parent #22564 remains open.

What changed

@objectstack/runtime (the one published change; patch changeset)

  • New dispatcher domain packages/runtime/src/domains/webhooks.ts, registered in HttpDispatcher beside /approvals/act (ADR-0076 D11: the dispatcher owns registration, one route bridges to one service slot). It claims exactly /webhooks/redeliver (match: 'exact') for POST only, resolves the request kernel's webhooks slot per request, and forwards context.request unread to IWebhookService.handleRedeliver, returning that Response untouched.
  • Absent slot, or a service without handleRedeliver: 501 NOT_IMPLEMENTED (an existing standard code), with a message naming which. Never ROUTE_NOT_FOUND.
  • A member that throws: sanitised 500 INTERNAL_ERROR, original error logged. A request whose body a transport already consumed: also 500, logged, and the member is not asked (it would otherwise answer "Request body must be JSON." for a well-formed request).
  • No preamble-gate change. The domain adds no credential rule of its own: the member judges the caller from the request through the auth service, as the self-hosted mount does. Unlike /approvals/act, this path is NOT exempted from either dispatcher gate (no ADR-0069 allow-list entry, no membership skip), so the hosted door is stricter than the mount, never looser. packages/core is untouched.
  • route-ledger.ts: one server-only row, POST /webhooks/redeliver (no SDK method exists and none is declared); census 85 to 86.

Pins and bookkeeping (no published surface)

  • scripts/check-route-envelope.mjs: declares webhooks.ts with handBuilt: 0.
  • packages/qa/http-conformance: devDependencies on @objectstack/plugin-webhooks and @objectstack/service-messaging, both aliased to source (anchored) in its vitest config, so one suite boots the real plugin on both doors; pnpm-lock.yaml gains the 6-line importer link. scripts/engine-double-contract.pinned.json records that suite's pinned fake-engine findOne.
  • packages/qa/dogfood: a new enforced authz row, webhook-redeliver-session, classifies the new dispatcher-domain key (the baseline is untouched, still 30); the probe census and both matrix headers move 85 to 86 rows, 22 to 23 domains, 21 to 22 covers keys; the showcase live-mount parity gate pins the row as served by plugin-webhooks' raw-app mount on a self-hosted boot (the /approvals/act reason).

The PM's four mechanism assumptions, as measured

  1. Premise: TRUE. Measured on main b7cd1af9d before any change, with the real WebhookOutboxPlugin over the real MessagingService on a LiteKernel with no http.server, under the real createHonoApp: the webhooks slot held handleRedeliver (a function), and POST /api/v1/webhooks/redeliver answered 404 ROUTE_NOT_FOUND ("Route Not Found: /webhooks/redeliver") for an authenticated and an anonymous caller alike.
  2. Slot timing: held. plugin-webhooks registers the slot at kernel:ready, only once its veto installs (bootRedeliverDoor). The domain reads it per request and records nothing; a pin registers the slot after a request was answered 501 and the next request reaches the member.
  3. Anonymous: both doors answer the same bytes. Self-hosted mount: 401 UNAUTHENTICATED, body {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in to redeliver webhook deliveries."}}. Hosted door after this change: the same status, headers and body. Neither dispatcher gate decides anything for an anonymous caller (membership returns early with no user; the auth gate needs a session), so the member's own refusal is the answer. Nothing needed an allow-list or gate change, and none was made.
  4. Method. Measured on both doors: the self-hosted door answers every non-POST (GET, PUT, PATCH, DELETE, HEAD) with plugin-hono-server's 404 ENDPOINT_NOT_FOUND; the hosted door answers the dispatcher's 404 ROUTE_NOT_FOUND. The hosted answer is pinned, and the member never runs for a non-POST (spied on the real service; the outbox row stays dead).

Pins

  • packages/runtime/src/domains/webhooks-redeliver.test.ts (13): the real HttpDispatcher on a kernel with no http.server and a stub member that reads its own JSON body. Forwarding with the body unread; the member's refusal passed through; the exact POST claim (no other method, sub-path or sibling); 501 for an absent slot and for an absent member; per-request slot resolution; the sanitised 500s; and the gates: a signed-in non-member refused 403 PROJECT_MEMBERSHIP_REQUIRED on both spellings, a gated session refused 403 by ADR-0069, while a member of the environment and an anonymous caller reach the member.
  • packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts (7), the end-to-end pin this card owes (plugin-webhooks: implement the declared redeliver member, read http.server before the http-server alias, and keep the self-hosted raw-app mount byte-unchanged (webhooks segment 3 of #22564's stage 2) #22756 landed first): the real plugin on both doors. Through createHonoApp, an authenticated POST replays a finished delivery and the row moves dead to pending in the real outbox (so the JSON body crossed the catch-all unread); ten refusals (anonymous 401, not-JSON and empty 400, missing and blank 400, unknown and cross-organization 404, not-finished, never-sent and veto 409) answer the same status, code, headers and body bytes at both doors (row ids masked); non-POST as above; the typed absence across the segment boundary with the member removed from the real service, with the slot left empty by the plugin's own boot (a messaging service that cannot take the veto), and with no plugin at all, each 501 NOT_IMPLEMENTED, with /webhooks/redeliverx as the route-miss control; and the CONTROL below.
  • CONTROL, kernel with http.server: the route is mounted once, as POST, on the raw app, and the mount answers with its own bytes (no dispatcher httpStatus / route / hint keys), equal to the bytes measured on the base commit. This PR adds no mount there: dispatcher-plugin.ts mounts explicit routes and is untouched, and the showcase parity gate (a real self-hosted composition) still sees no port mount for the path.

Ablations (one-off, from committed state, through scripts/ablation-replace.mjs, restore proven by blob hash equal to HEAD, empty git diff HEAD and a clean git status --porcelain)

  • Registration removed (the register(createWebhooksRedeliverDomain(...)) call replaced by void createWebhooksRedeliverDomain; so the DTS build still passes noUnusedLocals), runtime dist/ rebuilt, ablation-dist-preflight --absent green (marker absent from all 6 built files; tree mutate leg confirmed). Unit pins: 10 of 13 red, every one on ROUTE_NOT_FOUND / "Route Not Found: /webhooks/redeliver"; the 3 that stay green are the three gate pins (both gates run before route resolution, so they refuse with or without the domain). Wire pins: 5 of 7 red (404 where 200 or 501 was expected, and the anonymous parity row); the 2 that stay green are the non-POST pin and the self-hosted CONTROL, neither of which depends on the domain. Restore leg: blob equal to HEAD, tree clean, rebuilt, marker present in 2 built files, 13 of 13 and 7 of 7 green.
  • Typed-absence guards, each disabled on purpose (unit level; the runtime suite resolves the domain from source). Slot guard off: exactly the two slot-absence pins red (TypeError: Cannot read properties of undefined (reading 'handleRedeliver')). Member guard off: exactly the member-absence pin red (500 INTERNAL_ERROR instead of 501 NOT_IMPLEMENTED). Restored, 13 of 13 green. The wire suite's three absence cases remove the member and the slot on the real composition instead (see Pins).
  • Showcase parity pin renamed: the gate reports POST /webhooks/redeliver — LEDGERED BUT NOT MOUNTED plus the stale pin (2 of 8 red), and the boot log shows [webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver, so the pin is load-bearing and its reason is the measured one. Restored, 8 of 8.

Verification

Measured on be4051ae2 (this branch with main efcbac73c merged; after the merge pnpm install --frozen-lockfile, a full turbo run build of the workspace, 73 of 73 tasks, and rm -rf packages/runtime/.objectstack).

  • @objectstack/runtime: local project in three chunks, 104 + 123 + 123 files, 1985 + 1520 + 1546 tests passed (19 skipped); repo project 3 files, 751 passed; typecheck (src + test layer) 0, the new test file counted in the test-layer program with --listFiles.
  • @objectstack/http-conformance: 11 files, 117 passed; typecheck 0.
  • @objectstack/dogfood: authz-conformance.test.ts, authz-probe-blind-spot.test.ts and route-ledger-live-mount-parity.dogfood.test.ts, 3 files, 100 passed; typecheck 0. The rest of that suite is CI's.
  • Gates: dispatch-gates --commands derives 100 families for this diff (the dispatch list's 53 without pnpm lint, plus 47 the diff adds); all 100 exit 0 on be4051ae2, and dispatch-gates --ran with every exit recorded reads 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN. Two were red on the way and fixed in this diff: check:route-ledger-census (85 to 86) and check:engine-double-contract (the new suite's fake findOne now opens with assertEngineFindOnePredicate; the pinned ledger records it). check:dual-build-cjs-loads first refused with its prerequisite (8 packages without dist/); after the full build it measured 107 entries across 66 packages.
  • Lint, narrowed and proven: the population is eslint.config.mjs's own files globs minus NEVER_LINTED, which takes 12 of this diff's 16 files (the changeset, two JSON files and the lockfile are outside it); eslint --no-inline-config --format json over those 12 reports 12 files, 0 errors, 0 warnings, 0 fatal, none ignored. Invariance: the config enables no type-aware linting (stated in the config itself) and the diff touches none of its inputs, so it cannot move the verdict of an untouched file. The full pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

… kernels with no raw app

The hosted door for plugin-webhooks' redeliver endpoint: the dispatcher
forwards the request, body unread, to the request kernel's `webhooks`
slot (`IWebhookService.handleRedeliver`), resolved per request, and
returns the member's Response as it is. An absent slot or member answers
501 NOT_IMPLEMENTED, never ROUTE_NOT_FOUND. One server-only ledger row,
the route-envelope declaration, and the conformance package's devDeps
for the end-to-end pin.

Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4
Co-authored-by: Claude <noreply@anthropic.com>
…patcher

Forwarding with the body unread, the exact POST claim, the typed 501s,
per-request slot resolution, the sanitised 500s, and that both preamble
gates (membership, ADR-0069) apply to this path.

Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4
Co-authored-by: Claude <noreply@anthropic.com>
…al plugin-webhooks member on both doors

The end-to-end pin: createHonoApp over a kernel with no http.server
reaches the real member (a replay lands in the outbox), every refusal,
anonymous first, equals the self-hosted mount's bytes, no non-POST
reaches the member, the three absence shapes answer 501, and the
self-hosted kernel keeps its single raw-app mount.

Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4
Co-authored-by: Claude <noreply@anthropic.com>
… pin it unobservable on the showcase boot

One enforced authz row (`webhook-redeliver-session`) covers the new
dispatcher-domain key; the probe census and its prose move 85 -> 86 rows,
22 -> 23 domains; the live-mount parity gate pins the row as served by
plugin-webhooks' raw-app mount on a self-hosted boot.

Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4
Co-authored-by: Claude <noreply@anthropic.com>
…e with the engine predicate

check:engine-double-contract pins every engine double's findOne to
assertEngineFindOnePredicate; the pinned ledger records the new file.

Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/http-conformance, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/qa/http-conformance/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via /api/v1/webhooks/redeliver (route, a path literal in a comment on a changed line))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/qa/http-conformance/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 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 — 26 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 a6f3a13f2aea4275e2984709461e7f4b1201e17e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1bfea7ddf95f9a01666cbc0d678ce8bab5e8240f — the merge of head be4051ae272fa47a65001568c788f58d3a183317 into base a6f3a13f2aea4275e2984709461e7f4b1201e17e, 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 1bfea7ddf95f9a01666cbc0d678ce8bab5e8240f && git checkout 1bfea7ddf95f9a01666cbc0d678ce8bab5e8240f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a6f3a13f2aea4275e2984709461e7f4b1201e17e be4051ae272fa47a65001568c788f58d3a183317 && git checkout -B drift-repro a6f3a13f2aea4275e2984709461e7f4b1201e17e && git merge --no-ff be4051ae272fa47a65001568c788f58d3a183317

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a6f3a13f2aea4275e2984709461e7f4b1201e17e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants