Repository navigation
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
Conversation
… 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>
…bhooks-redeliver-member
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: PR #22797 on card #22756, webhooks segment 3 of #22564's stage 2 under the maintainer's ruling 「A + 扫类」 on #22438 (director record Check-runs on the head: 35 check names, latest run per name, all completed: 31 ① Derived judgments
② Semver levelClause-②: yes
③ Boundary flags
Implemented-by: 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 |
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 split6104808418). It implements the member that segment 1 (#22754, landed ase84aeb36ce) declares. Dispatched by thedomain:servicesseat 1 (seat post #6021), claim6105954152, sessionsession_01CBAfsWMSfM3EToQGVStEcp.What changes
All runtime changes are in
packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts.webhooksservice and its member. Atkernel:readythe plugin registers thewebhooksslot. It holds anIWebhookServicewhosehandleRedeliver(request)servesPOST /api/v1/webhooks/redeliverfrom a web-standardRequestand answers aResponse. It needs no raw app, no Hono context and nohttp.server.serveRedeliver(ctx, messaging, c). The mount calls it with the real Hono context. The member calls it withrequestDoorContext(request), a three-member view of theRequest: its headers,request.json(), and ajson(body, status)that builds theResponsethe way Hono'sc.jsondoes. 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 -wshows the moved block as unchanged.redeliverbutton that sends UNSIGNED #8069 veto is installed without realtime or auto-enqueue (seat decision6105715713).installRedeliverGuardused to run only insidebootAutoEnqueue. That function returns early whenautoEnqueueisfalse, 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 thewebhooksslot only after the veto is installed. If the messaging service cannot take the veto, the slot stays empty and the existingerrorline now also says so. The veto still readssubscriptionsObjectfrom the auto-enqueue options.http.serverbefore the alias.registerAdminRoutesnow reads['http.server', 'http-server']throughtryGetService, which tries each name in its owntry, asplugin-approvalsdoes. A host that registers its server only ashttp.server(runtime.ts'sconfig.serverpath) now gets the route. A host that registers both names (plugin-hono-server) gets it once.src/webhook-redeliver-member.test.ts(new, 14 tests).honojoins the package's devDependencies (^4.13.9, the same asplugin-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.scripts/engine-double-contract.pinned.jsongains the one row thatcheck:engine-double-contractasked for. The new test'sfindOnedouble opens withassertEngineFindOnePredicate, and the gate's own remedy is--write..changeset/22756-webhooks-redeliver-member.md,minor, carryingClause-②: yes.Pins (
src/webhook-redeliver-member.test.ts)The harness is a real
LiteKerneland the plugin's real boot. The plugin'skernel:readyhook installs the veto, registers the slot and mounts the route. Messaging is the realMessagingServiceover the realMemoryHttpOutbox, 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'swebhooksslot.The member, on a kernel with no
http.server(the hosted shape). It answers every arm of the contract withcode+status,Content-Type: application/jsonand the envelope:401 UNAUTHENTICATED;400 INVALID_REQUEST;400 MISSING_REQUIRED_FIELDfor a missing, a non-string and a blank id;404 RESOURCE_NOT_FOUNDfor an unknown row and another organization's row;409 DELIVERY_NOT_ELIGIBLEfor an unfinished row;409 DELIVERY_NEVER_SENTfor a parked row;409 DELIVERY_NOT_ELIGIBLEfrom the veto, for a gone subscription;500 INTERNAL_ERRORwhen there is no outbox;200with{ id, status: 'pending' }, after which the row really ispending.No refusal writes anything. The caller's session is read with
inProcessSessionReadInput, so a cookie request reads withdisableRefresh. Nothing is mounted.Parity. On a kernel with
http.serverand its alias on one Hono app, the same 11 refusal requests go to the member and then toapp.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.The composition, and no double mount:
POSTroute;http.serveralone: one;http.server's server and not on the alias's;http.server: nothing is mounted, and the "mounted" line is never logged.The veto without realtime. A redelivery of a delivery whose subscription is gone is refused with
409 DELIVERY_NOT_ELIGIBLEby both faces in two cases: with no realtime service, and with realtime present butautoEnqueue: false. A control row on the same kernel, whose subscription stands, replays200. With a messaging service that has noregisterRedeliverGuard, thewebhooksslot is absent and theerrorline is logged once.The mount on
main, measured directlyThis 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 withhttp.serverandhttp-serveron one Hono app, with realtime present, so thatmain'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.d157cbdc6c, equal to HEAD's.e84aeb36c's blob60dc0b99b9(the hash was checked on disk;serveRedeliverhits 0).fa158c07f177…both). The route list is['POST']in both.git checkout HEAD -- ABSOLUTE_PATH. The blob is back tod157cbdc6c,git diff HEADis 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.tsat40a52b4e5, the plugin blobd157cbdc6cthat 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 HEADempty).main's prerequisites (installed only whenopt !== falseand realtime resolves)expected 200 to be 409), the matrix's veto case, parity's409 veto(expected { status: 200, …} to deeply equal { status: 409, …}), and the no-veto-no-slot pin.['http-server'](main's)http.serveralone, and the split servers (expected [] to deeply equal [ 'POST' ]).the webhooks slot holds no handleRedeliver,expected 'undefined' to be 'function').expected { Object (handleRedeliver) } to be undefined.expected [ 'POST', 'POST' ] to deeply equal [ 'POST' ]. The first attempt was a no-op: its replacement contained its anchor,ablation-replacerefused because the anchor count did not drop (1 before, 1 after), and nothing ran. The second attempt used aforloop that does not contain the anchor.Content-Typechanged toapplication/json; charset=UTF-8Verification (HEAD
c66f74076;origin/mainmerged ata2e94c2a0)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-typecheckis OK, and the new test is in both programs:--listFilescounts 1 undertsconfig.jsonand 1 undertsconfig.test.json.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 87 commands atc66f74076, and all 87 ran with exit 0.--ranreports 87 derived, 87 run, 0 NOT-MEASURED and 0 UNRUN. The same 87 had also run green at04d3756b4, before the merge. The first run at919541c71had one finding,check:engine-double-contractasking for the ledger row above, and one--writefixed it.pnpm --filter '@objectstack/plugin-webhooks^...' run build).check:dual-build-cjs-loadsandcheck:i18nfirst refused withPREREQUISITE NOT MET(exit 3). Afterturbo run build --filter='!@objectstack/docs'both exit 0. After the merge oforigin/mainthere was one more full build (73 tasks), then the package test, typecheck and the 87 gates again.origin/mainhas since moved toa8f24b092(feat(verify): the handle observes the writes the engine receives, a hook's refused write included #22781:packages/verifyandpackages/qa/dogfoodonly, disjoint from this diff). It is not merged here; the merge queue rebuilds on it.NOT MEASURED: the end-to-end pin through the dispatcher
The pin through the runtime dispatcher's
POST /webhooks/redeliverdomain is NOT MEASURED here. That domain is #22755 (domain:cli), which has not landed. Onorigin/maina8f24b092,git grep -n -i webhooksoverpackages/runtime/src/http-dispatcher.tsandpackages/runtime/src/domains/exits 1 with no hits. The control,git grep -c -i approvalsonpackages/runtime/src/domains/approvals.tsat 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'swebhooksslot, 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(devDependencyhono) andpnpm-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[]isdist,README.md,CHANGELOG.md).packages/spec,packages/runtime,packages/coreand 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.serverfirst, which the card asks for). The answers onmain'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
createWebhookRedeliverGuardon a subscription withactive: falseand no secret, and it returnedundefined, 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 with409 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-
POSTdispatcher domain, and the contract's status table names no405. A forwarded non-POSTwith no body would be told400 INVALID_REQUESTafter the session check.No new log line on a kernel without a raw app. The mount's existing
debugline is unchanged. A line naming the dispatcher domain belongs with runtime: an exact/webhooks/redeliverdispatcher 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-②: yesis 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