Skip to content

feat(plugin-webhooks): the webhooks service serves the redeliver door from a Request, and the veto no longer waits for realtime (webhooks segment 3 of #22564) - #22797

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22756-webhooks-redeliver-member
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22756-webhooks-redeliver-member

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22756
Clause-②: yes

Segment 3 of the webhooks member of #22564's stage 2, under the maintainer's ruling 「A + 扫类」 on #22438 (director record 6079645593, item 2; triage split 6104808418). It implements the member that segment 1 (#22754, landed as e84aeb36ce) declares. Dispatched by the domain:services seat 1 (seat post #6021), claim 6105954152, session session_01CBAfsWMSfM3EToQGVStEcp.

What changes

All runtime changes are in packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts.

  • The webhooks service and its member. At kernel:ready the plugin registers the webhooks slot. It holds an IWebhookService whose handleRedeliver(request) serves POST /api/v1/webhooks/redeliver from a web-standard Request and answers a Response. It needs no raw app, no Hono context and no http.server.
  • One body, two faces. The redeliver door's body moved, unedited, out of the mount's inline handler into one private method, serveRedeliver(ctx, messaging, c). The mount calls it with the real Hono context. The member calls it with requestDoorContext(request), a three-member view of the Request: its headers, request.json(), and a json(body, status) that builds the Response the way Hono's c.json does. So both faces share one copy of each rule: the session check, the active-organization tenant, MessagingService.redeliverHttp, and the status for each outcome. The mount does not call the member, and the member mounts nothing. git diff -w shows the moved block as unchanged.
  • The [security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED #8069 veto is installed without realtime or auto-enqueue (seat decision 6105715713). installRedeliverGuard used to run only inside bootAutoEnqueue. That function returns early when autoEnqueue is false, or when ObjectQL, Realtime or Messaging is missing. A new step, bootRedeliverDoor, now installs the veto with only the engine and messaging. It runs after the declared-webhook bootstrap and before both the enqueuer and the mount. It registers the webhooks slot only after the veto is installed. If the messaging service cannot take the veto, the slot stays empty and the existing error line now also says so. The veto still reads subscriptionsObject from the auto-enqueue options.
  • http.server before the alias. registerAdminRoutes now reads ['http.server', 'http-server'] through tryGetService, which tries each name in its own try, as plugin-approvals does. A host that registers its server only as http.server (runtime.ts's config.server path) now gets the route. A host that registers both names (plugin-hono-server) gets it once.
  • Tests: src/webhook-redeliver-member.test.ts (new, 14 tests). hono joins the package's devDependencies (^4.13.9, the same as plugin-approvals; the workspace override resolves it to the existing 4.13.12, +3 lockfile lines), so the mount is driven through a real Hono app.
  • Ledger: scripts/engine-double-contract.pinned.json gains the one row that check:engine-double-contract asked for. The new test's findOne double opens with assertEngineFindOnePredicate, and the gate's own remedy is --write.
  • Changeset: .changeset/22756-webhooks-redeliver-member.md, minor, carrying Clause-②: yes.

Pins (src/webhook-redeliver-member.test.ts)

The harness is a real LiteKernel and the plugin's real boot. The plugin's kernel:ready hook installs the veto, registers the slot and mounts the route. Messaging is the real MessagingService over the real MemoryHttpOutbox, so each refusal comes from the outbox: the tenant scope, the row-local refusals and the veto. The member is always read off the started kernel's webhooks slot.

  1. The member, on a kernel with no http.server (the hosted shape). It answers every arm of the contract with code + status, Content-Type: application/json and the envelope:

    • 401 UNAUTHENTICATED;
    • 400 INVALID_REQUEST;
    • 400 MISSING_REQUIRED_FIELD for a missing, a non-string and a blank id;
    • 404 RESOURCE_NOT_FOUND for an unknown row and another organization's row;
    • 409 DELIVERY_NOT_ELIGIBLE for an unfinished row;
    • 409 DELIVERY_NEVER_SENT for a parked row;
    • 409 DELIVERY_NOT_ELIGIBLE from the veto, for a gone subscription;
    • 500 INTERNAL_ERROR when there is no outbox;
    • 200 with { id, status: 'pending' }, after which the row really is pending.

    No refusal writes anything. The caller's session is read with inProcessSessionReadInput, so a cookie request reads with disableRefresh. Nothing is mounted.

  2. Parity. On a kernel with http.server and its alias on one Hono app, the same 11 refusal requests go to the member and then to app.fetch. Status, the full header list and the body bytes are equal for each. A replay through each face, on twin rows, matches on status and headers, and on the body once the row id is replaced.

  3. The composition, and no double mount:

    • both names on one server: exactly one POST route;
    • http.server alone: one;
    • the alias alone: one;
    • two servers under the two names: the route is on http.server's server and not on the alias's;
    • with no http.server: nothing is mounted, and the "mounted" line is never logged.
  4. The veto without realtime. A redelivery of a delivery whose subscription is gone is refused with 409 DELIVERY_NOT_ELIGIBLE by both faces in two cases: with no realtime service, and with realtime present but autoEnqueue: false. A control row on the same kernel, whose subscription stands, replays 200. With a messaging service that has no registerRedeliverGuard, the webhooks slot is absent and the error line is logged once.

The mount on main, measured directly

This checks that the route answers the same bytes as the one that ships on main, and not only the same bytes as this branch's member. A scratch capture test, not committed, booted the plugin with http.server and http-server on one Hono app, with realtime present, so that main's file also installs the veto. It recorded the mount's status, headers and body for 12 requests, with row ids replaced by fixed labels, plus the route list. The 12 requests were the 11 refusals above and one replay.

  • HEAD leg: plugin blob d157cbdc6c, equal to HEAD's.
  • main leg: the file was replaced with e84aeb36c's blob 60dc0b99b9 (the hash was checked on disk; serveRedeliver hits 0).
  • Result: the two captures are byte-identical (sha256 fa158c07f177… both). The route list is ['POST'] in both.
  • Restore: git checkout HEAD -- ABSOLUTE_PATH. The blob is back to d157cbdc6c, git diff HEAD is empty, and the scratch file is removed. The trap was armed on EXIT, INT and TERM.

Ablations (each through scripts/ablation-replace.mjs)

Each ablation was run against src/webhook-redeliver-member.test.ts at 40a52b4e5, the plugin blob d157cbdc6c that is also HEAD's. Each mutation was proven on disk (the anchor count 1 to 0, the blob changed), and each restore was proven (blob equal to HEAD, git diff HEAD empty).

# Mutation Result
A1 The veto put back behind main's prerequisites (installed only when opt !== false and realtime resolves) 5 failed. Both veto pins (expected 200 to be 409), the matrix's veto case, parity's 409 veto (expected { status: 200, …} to deeply equal { status: 409, …}), and the no-veto-no-slot pin.
A2 The server read as ['http-server'] (main's) 2 failed: http.server alone, and the split servers (expected [] to deeply equal [ 'POST' ]).
A2b The alias read first 1 failed: the split servers.
A3 The member renamed away 10 failed. Every member-reading pin (the webhooks slot holds no handleRedeliver, expected 'undefined' to be 'function').
A4 The slot registered whether or not the veto is installed 1 failed: expected { Object (handleRedeliver) } to be undefined.
A5 The route registered twice 4 failed: expected [ 'POST', 'POST' ] to deeply equal [ 'POST' ]. The first attempt was a no-op: its replacement contained its anchor, ablation-replace refused because the anchor count did not drop (1 before, 1 after), and nothing ran. The second attempt used a for loop that does not contain the anchor.
A6 The member's Content-Type changed to application/json; charset=UTF-8 4 failed: the matrix, the replay and both parity pins.

Verification (HEAD c66f74076; origin/main merged at a2e94c2a0)

  • pnpm --filter @objectstack/plugin-webhooks test: 17 files, 182 tests passed. The new file passes 14 of 14.
  • pnpm --filter @objectstack/plugin-webhooks typecheck: exit 0. check:test-typecheck is OK, and the new test is in both programs: --listFiles counts 1 under tsconfig.json and 1 under tsconfig.test.json.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 87 commands at c66f74076, and all 87 ran with exit 0. --ran reports 87 derived, 87 run, 0 NOT-MEASURED and 0 UNRUN. The same 87 had also run green at 04d3756b4, before the merge. The first run at 919541c71 had one finding, check:engine-double-contract asking for the ledger row above, and one --write fixed it.
  • Build: the dependency closure was built first (pnpm --filter '@objectstack/plugin-webhooks^...' run build). check:dual-build-cjs-loads and check:i18n first refused with PREREQUISITE NOT MET (exit 3). After turbo run build --filter='!@objectstack/docs' both exit 0. After the merge of origin/main there was one more full build (73 tasks), then the package test, typecheck and the 87 gates again.
  • origin/main has since moved to a8f24b092 (feat(verify): the handle observes the writes the engine receives, a hook's refused write included #22781: packages/verify and packages/qa/dogfood only, disjoint from this diff). It is not merged here; the merge queue rebuilds on it.
  • Not run locally: CI's own job steps and the workflow-valued families that the derivation lists as such. CI owns those.

NOT MEASURED: the end-to-end pin through the dispatcher

The pin through the runtime dispatcher's POST /webhooks/redeliver domain is NOT MEASURED here. That domain is #22755 (domain:cli), which has not landed. On origin/main a8f24b092, git grep -n -i webhooks over packages/runtime/src/http-dispatcher.ts and packages/runtime/src/domains/ exits 1 with no hits. The control, git grep -c -i approvals on packages/runtime/src/domains/approvals.ts at the same ref, exits 0 with 28. Under the card body, whichever of #22755 and this PR lands second runs that pin. The pins here call the member through the kernel's webhooks slot, as the dispatch directs.

Acceptance notes

  • File surface. The claim lists packages/plugins/plugin-webhooks/src/** and the changeset. This PR also touches:

    • packages/plugins/plugin-webhooks/package.json (devDependency hono) and pnpm-lock.yaml (+3 lines), which the real-Hono pins need;
    • scripts/engine-double-contract.pinned.json (+5 lines, the gate-prescribed row).

    Neither changes what ships (files[] is dist, README.md, CHANGELOG.md). packages/spec, packages/runtime, packages/core and the docs are not touched.

  • The mount's source moved but its answers did not. The card asks that the raw mount stay byte-unchanged. This PR reads that as the mount's answers, which the dispatch's pin wording ("byte-equal answers for the same requests") also does. The handler body moved verbatim into serveRedeliver, the route callback is now one line, and the read order changed (http.server first, which the card asks for). The answers on main's mount were measured byte-equal above.

  • "Disabled" is not the veto's predicate. The dispatch's pin wording said "a delivery whose subscription is disabled". A scratch probe measured createWebhookRedeliverGuard on a subscription with active: false and no secret, and it returned undefined, so the delivery is allowed. The guard refuses a subscription that is gone, or one whose stored secret cannot be recovered (its docblock's cases 1 and 2). So the pins use a gone subscription. Whether a disabled subscription should also veto redelivery is outside this card, and nothing here changes it.

  • The veto now applies on more kernels. On a kernel without realtime, or with autoEnqueue: false, a webhook delivery whose subscription is gone, or whose secret cannot be recovered, used to be replayed. It is now refused with 409 DELIVERY_NOT_ELIGIBLE. That is the gap the seat decided to close. The changeset states it.

  • Method. The member does not check the request method. Its one caller is an exact-POST dispatcher domain, and the contract's status table names no 405. A forwarded non-POST with no body would be told 400 INVALID_REQUEST after the session check.

  • No new log line on a kernel without a raw app. The mount's existing debug line is unchanged. A line naming the dispatcher domain belongs with runtime: an exact /webhooks/redeliver dispatcher domain that forwards to the declared redeliver member, with a typed refusal for an absent slot (webhooks segment 2 of #22564's stage 2) #22755, which has not landed.

  • Clause-②: yes is copied as the claim spells it. The PR owes a contract-review-tier record before the queue, which the seat arranges.


Generated by Claude Code

… from a Request, beside a veto installed without realtime (#22756)

WIP: implementation; tests follow.

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
… drive the mount through a real Hono app (#22756)

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…mount, the composition and the realtime-free veto (#22756)

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…the realtime-free veto (#22756)

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…est's pinned findOne double (#22756)

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
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 1 package(s): @objectstack/plugin-webhooks, touching 17 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via INTERNAL_ERROR (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/api/error-catalog.mdx (via INTERNAL_ERROR (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), MISSING_REQUIRED_FIELD (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/api/error-handling-client.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/api/metadata-api.mdx (via INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/automation/webhooks.mdx (via DELIVERY_NEVER_SENT (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), DELIVERY_NOT_ELIGIBLE (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), MISSING_REQUIRED_FIELD (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), /api/v1/webhooks/redeliver (route, a path literal in a comment in WebhookOutboxPlugin; a path literal in a comment on a changed line; a path literal in registerAdminRoutes))
  • content/docs/data-modeling/import-mappings.mdx (via INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/kernel/contracts/metadata-service.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/permissions/authentication.mdx (via INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/permissions/sso.mdx (via INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/plugins/development.mdx (via INTERNAL_ERROR (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/plugins/index.mdx (via http.server (literal, a string literal in registerAdminRoutes))
  • content/docs/protocol/kernel/error-handling.mdx (via INTERNAL_ERROR (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), MISSING_REQUIRED_FIELD (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/ui/forms.mdx (via INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via INTERNAL_ERROR (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver), RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/releases/v17/17-2.mdx (via DELIVERY_NOT_ELIGIBLE (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/releases/v17/17-5.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))
  • content/docs/releases/v17/17-6.mdx (via INVALID_REQUEST (literal, a string literal in registerAdminRoutes; a string literal in serveRedeliver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 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 a8f24b092cb6b3188ead7d8b3821c813cfdea6e9 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 a8f24b092cb6b3188ead7d8b3821c813cfdea6e9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c66f740762b5ccdfa544b1d38bdf6df8989635cf
Local-runs: none

PR #22797 on card #22756, webhooks segment 3 of #22564's stage 2 under the maintainer's ruling 「A + 扫类」 on #22438 (director record 6079645593, item 2). Inputs, and nothing else: the card body and its comments other than the dispatching seat's own conclusion (pointer 6105715713, unlock scan 6105945267, claim 6105954152, os-dev-report 6106627282); the PR body, its six-file list and the net diff against main at the head (+631/-81); the check-runs on the head. For ①, the declared contract on origin/main, packages/spec/src/contracts/webhook-service.ts (landed by #22754 as e84aeb36ce), and the #22578 precedent (PR #22641, its record 6095645330). The plugin file was read at the head for the helpers the diff only calls (resolveSession, tryGetService, getMessaging, the kernel:ready hook). Nothing was built, run or re-run.

Check-runs on the head: 35 check names, latest run per name, all completed: 31 success, 4 skipped (Console Pin Gate, which runs only when the pin moves; Packed-tarball smoke (opt-in); and the label-event re-runs of Auto Label and Check PR Size, whose push-event runs on this same head were success), 0 failure, 0 in progress; the last to finish was Lint & Repo Gates at 2026-10-11T07:55:14Z. All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check (its four lanes too), Test Core (its six shards too), Dogfood Regression Gate (its three shards and Verify CLI too), Build Core, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard; Check Changeset, Validate Package Dependencies, Build Docs and the card, branch and single-writer guards are success as well. Those conclusions are the gate verdicts for every derived family. No verdict was rendered while any of them was still in progress, and the head did not move during the wait.

① Derived judgments

  1. A new kernel service slot, webhooks, holding an IWebhookService — right. WEBHOOKS_SLOT = 'webhooks' is the slot the contract's docblock names, after the plugin's capability token. The occupant is typed const service: IWebhookService, so the member's shape — handleRedeliver(request: Request) returning a Promise of a Response — is checked by the type-check lanes. The slot is registered at kernel:ready, after the veto, in the same hook that already registers webhook.autoEnqueuer, so a ready-phase registration is this plugin's established pattern on the real kernels, not a new one. Consequence for the caller, escalated in ③: the slot does not exist before kernel:ready has run, so the dispatcher domain must resolve it per request.
  2. The member and the mount run one body — right, and the right reading of the card. serveRedeliver(ctx, messaging, c) is the mount's former inline handler moved out whole; every rule (session via resolveSession, the active-organization tenant, messaging.redeliverHttp, the 401/400/400/404/409/409/500/200 table) appears once in the diff, and the pre-image's copy is deleted. The mount hands it the real Hono context; the member hands it requestDoorContext(request). The mount does not call the member, and the member mounts nothing. The card asks for the member "through the same service code the raw mount calls"; on main that code was the inline handler, so sharing it required the extraction. This differs from plugin-approvals: implement the transport-neutral action-page member, keeping the self-hosted raw-app mount byte-unchanged (segment 4 of ruling A on #22438) #22578, where the logic already lived in service methods, and the difference is forced by the pre-image, not chosen.
  3. The three-member view of the Request is sufficient — right. RedeliverDoorContext exposes req.raw.headers, req.json() and json(body, status). resolveSession (read at the head) reads only c.req.raw.headers, through inProcessSessionReadInput; the door body reads only c.req.json() and answers only through c.json. request.json() and Hono's c.req.json() both fail on a body that is not JSON (an empty body included), so 400 INVALID_REQUEST is reached the same way at both faces; the parity pin covers the empty-body case explicitly. A Request whose body was already consumed also lands in that catch, which is the contract's forwarding rule read from the other side.
  4. The member's Response is Hono's c.json shape — right, measured. new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }), status defaulting to 200. Parity is pinned as status, the full header list and body bytes for eleven refusals and one replay through app.fetch on a real Hono app at the locked hono@4.13.12, and ablation A6 (a charset suffix) fails four pins. Those pins run in Test Core.
  5. The [security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED #8069 veto moves out of the auto-enqueue prerequisite — right, and strictly wider. bootAutoEnqueue installed it only when opt !== false and ObjectQL, Realtime and Messaging all resolved; bootRedeliverDoor installs it on the engine (['objectql', 'data'], the same lookup) and messaging alone, and runs before bootAutoEnqueue in the same hook, so the "veto before the first enqueued row" invariant holds unchanged. Every kernel that installed the veto on main installs it here, and kernels without realtime or with autoEnqueue: false now do too. subscriptionsObject is read from the same options as before. Ablation A1 (the veto put back behind the old prerequisites) fails five pins.
  6. The slot only beside the veto — right. installRedeliverGuard now returns whether it installed; a messaging service without registerRedeliverGuard logs the existing error line once, now also saying the service is not registered, and the slot stays empty, so the caller answers the contract's typed absence rather than serving a door with no veto. Ablation A4 fails the pin. The enqueuer still starts in that case, as it did on main; nothing there got looser.
  7. http.server before the http-server alias — right, as the card orders. tryGetService(ctx, ['http.server', 'http-server']) asks each name in its own try, first answer wins, so a host registering one server under both names mounts once and a host registering only http.server (the runtime.ts config.server path) now mounts at all. The composition pins cover both names, each alone, and two distinct servers (the route lands on http.server's and not on the alias's); A2 and A2b fail them.
  8. The mount's answers are unchanged, and it is mounted once — right. The route is one rawApp.post on the same path with one handler. The dev measured the mount's twelve answers plus its route list byte-identical between the head blob d157cbdc6c and main's 60dc0b99b9 (the pre-image index line of the diff names the same two blobs). A5 (the route registered twice) fails four pins.
  9. Log lines — right levels, no tracker numbers. The new warn when the engine or messaging is absent is a functional degradation, visible and at the right level; the amended error is the pre-existing durability line. Neither runtime string carries an issue number.
  10. No public export changes in the package. WEBHOOKS_SLOT, RedeliverDoorContext and requestDoorContext are module-private; bootRedeliverDoor and serveRedeliver are private methods; installRedeliverGuard's return type changed on a private method. What the package newly publishes is the webhooks service slot and the widened veto, both named in the changeset.
  11. Test and ledger surface — right. The new test imports only this package's sources, its workspace dependencies and the new hono devDependency; it boots a real LiteKernel with the plugin's real boot and the real MessagingService over MemoryHttpOutbox, so each refusal is the outbox's. Its findOne double opens with assertEngineFindOnePredicate, and scripts/engine-double-contract.pinned.json gains exactly that one row, as the gate prescribes. hono is a devDependency; dist/, exports and files are untouched.
  12. Nothing governed, nothing out of lane. None of the six paths is a governed surface (Governed Surface Queue Guard green); packages/spec, packages/runtime, packages/core and the docs are untouched, as the claim required. Same-repo head, draft, 712 changed lines.

② Semver level

Clause-②: yes

.changeset/22756-webhooks-redeliver-member.md declares @objectstack/plugin-webhooks: minor and carries Clause-②: yes, as the PR body does. The diff publishes a new kernel service slot with one additive member, and a behavioural widening of a fail-closed guarantee the system already claims (the veto now refuses on kernels where it used to be absent): yes, at least minor, and minor is right. It removes or renames nothing an author can write, so no arm and no ADR-0087 marker is owed, and skip-changeset would be wrong. The changeset states the veto change in its body, which is the text an upgrading operator will read. The hono devDependency publishes nothing. The lockfile row records specifier: ^4.13.5 under a manifest that says ^4.13.9, the same pair the #22578 record observed on plugin-approvals and the same as the sibling importers; Build Core's frozen-lockfile install is green on this head, so it is an observation, not a ground.

③ Boundary flags

Implemented-by: claude/issue-22756-webhooks-redeliver-member
Reviewed-by: session_01CBAfsWMSfM3EToQGVStEcp

VERDICT: PASS

Rendered 2026-10-11T07:56Z on the head named above; the check-run reading is the one declared in the summary line.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 07:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 07:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 0984817 Oct 11, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22756-webhooks-redeliver-member branch October 11, 2026 08:35
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