Repository navigation
feat(spec): declare IWebhookService.handleRedeliver, the optional transport-neutral webhook redeliver member - #22767
Conversation
…nsport-neutral webhook redeliver member A new contract for the `webhooks` service slot (plugin-webhooks), with one optional member that serves POST /api/v1/webhooks/redeliver as a web-standard Request -> Response, so a dispatcher domain can reach the door on a kernel with no raw app. Type-level pins and a minor changeset. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…rvice Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…bhooks-redeliver-member
…bhooks-redeliver-member
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 139 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 249835428e6b659bcac711392f598effd3dc24ad && git checkout 249835428e6b659bcac711392f598effd3dc24ad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a18c514965f90e09fa9432c3e72e34426ebc03d6 06119a91ffd262d5c2880b310a42c423c72f4c55 && git checkout -B drift-repro a18c514965f90e09fa9432c3e72e34426ebc03d6 && git merge --no-ff 06119a91ffd262d5c2880b310a42c423c72f4c55
node scripts/docs-audit/affected-docs.mjs --json a18c514965f90e09fa9432c3e72e34426ebc03d6
|
Contract reviewServed-tier: Inputs read: card #22754 (body, claim ① Derived judgments
② Semver level
③ Boundary flags
Gates on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22754
Clause-②: yes (widening)
What this does
Segment 1 of the webhooks bridge in #22564's stage 2, under the maintainer's ruling on #22438 (director record
6079645593, verbatim 「A + 扫类」). It declares the forward target that a dispatcher domain will call forPOST /api/v1/webhooks/redeliver. On a kernel with nohttp.server(the hosted shape), that endpoint answers404 ROUTE_NOT_FOUNDtoday (stage-1 measurement6093135788, accepted at6093165613).Nothing implements or calls the member yet. The runtime dispatcher domain and the plugin-webhooks implementation are the next segments.
packages/spec/src/contracts/webhook-service.ts(new): the contractIWebhookServicefor thewebhooksservice slot. It has one optional member,handleRedeliver?(request: Request), which returns aPromiseof a web-standardResponse. Its docblock states five things:401 UNAUTHENTICATEDwith no signed-in user;400 INVALID_REQUESTfor a non-JSON body;400 MISSING_REQUIRED_FIELDwithout a usabledeliveryId;404 RESOURCE_NOT_FOUNDfor a row outside the caller's active organization;409withDELIVERY_NOT_ELIGIBLEorDELIVERY_NEVER_SENTwhen the outbox refuses the replay;500 INTERNAL_ERRORotherwise; and200with the row's id and new status.authservice, and scopes the replay to the caller's active organization. That is the self-hosted rule, so both kernel shapes judge the same caller by one rule in one place. It takes noExecutionContext.http.servertype in the signature.HttpDispatcher's exactPOST /webhooks/redeliverdomain, which forwards the request with its body unread. The path is not on the ADR-0069 allow-list, so the dispatcher door stays stricter than the self-hosted mount, never looser. The plugin's raw-app mount is not a caller, so the path is never mounted twice.webhooksslot or the member is absent, the caller answers a typed 404 or 501, neverROUTE_NOT_FOUND.packages/spec/src/contracts/index.ts: exports the new contract from@objectstack/spec/contracts.packages/spec/src/contracts/webhook-service.test.ts: the type-level pin.RedeliverIsOptionaluses the{} extends Pickform, becausetoEqualTypeOfcannot tell an optional member from a required one typed withundefined.RedeliverTakesOneRequestasserts the parameters are exactly oneRequest.RedeliverAnswersAResponseasserts the return type is aPromiseofResponse.BarrelExportsTheContractasserts the barrel exports this same contract.IWebhookServicecompiles and carries no member.packages/spec/api-surface/contracts.jsonandpackages/spec/export-origins/contracts.json: one regenerated line each, for the new export..changeset/22754-spec-webhooks-redeliver-member.md:@objectstack/specminor(pre modenext), with its ownClause-②: yes (widening)line.The slot: a new
webhookscontract, not the messaging service'sThe card makes the slot this segment's contract call, decided on the four axes. The PM's suggested route was the messaging service's contract, since
redeliverHttplives there. This PR departs from that suggestion, for the reasons below.What was measured first
webhook.autoEnqueuer(webhook-outbox-plugin.ts:317).redeliverHttphas 0 hits inpackages/spec/src(git grep exit 1). The messaging service registersmessagingandnotification(messaging-service-plugin.ts:213,:222). The candidate slot keys are thereforewebhooks(the plugin's platform capability token,platform-capabilities.ts; registered by nothing yet) andmessaging.MessagingService.redeliverHttp(id, { tenantId })resets a finished row topending, applies the tenant wall and the parked-row refusal, consults each producer's veto, and wakes the dispatcher. plugin-webhooks owns the DOOR:installRedeliverGuard, called before the auto-enqueuer writes its first row).One more finding: there is no messaging service contract to declare on.
ServiceSlotContractshas nomessagingentry, and noIMessagingServiceexists. The messaging route would mint a new contract file too, so both options cost one new contract.registerAdminRoutes) takes the session'suser.idandsession.activeOrganizationIdand a JSON body{ deliveryId }. It answers the envelope listed above. The docblock describes that behaviour, and the plugin is unchanged.The four axes
webhooksslot, the door exists exactly where self-hosted has it. A door on themessagingslot would also exist on a kernel without plugin-webhooks. Nobody asked for that door, and the webhook veto is never installed there./authand/approvals/actare the precedents (ADR-0076 D11). Under this choice, plugin-webhooks keeps the door's rules in one place for both kernel shapes. On themessagingslot, the runtime domain would hold a second copy of the authentication, body parsing and refusal-to-status mapping, beside the plugin's raw mount.redeliverHttpmakes itstenantIda required property typed as string-or-undefined, so a caller must decide it. The messaging route's real advantage was a typedtenantIdat the contract. It is not lost here; it stays at the one place that calls the operation.handleActionPagehad before its implementation landed. The messaging route would also need the dispatcher to detect plugin-webhooks' presence some other way, and no existing registration answers that:webhook.autoEnqueueris registered only when auto-enqueue starts.The precedent is kept, not departed from. The #22575 member shape (a web-standard
Requestin,Responseout), its docblock structure, its pin form and its changeset level are all reused.Why
handleRedeliverhandle…is this package's spelling for a function from aRequestto aResponse:IAuthService.handleRequest,IRealtimeService.handleUpgrade?andIApprovalService.handleActionPage?.Redeliveris the route's own last segment and the plugin's own word ("redeliver endpoint",registerRedeliverGuard).IWebhookService/handleRedeliverhad 0 hits anywhere outside this diff before it: objectstack git grep exit 1 (controlIApprovalServiceexit 0), and objectui at023f00d46exit 1 (controlIAuthService: 2 hits).Verification
Heavy runs went through
scripts/pm/os-verify-lock.sh. TheVERDICTline is quoted from each. The final head is06119a91ff: two merges oforigin/main(e86530088a, then7098acaef9) on top of the two change commits. The second merge touched nopackages/specfile.pnpm --filter @objectstack/spec buildat740277750d, and again after the first merge atbbac3326b9:VERDICT command-exit 0both times. The build wrote no tracked file.handleRedeliverhas 1 hit each indist/contracts/index.d.tsand.d.mts, and 0 hits in any emitted.js/.mjs/.cjs, so the change is type-only. After the second merge, the packages it touched were rebuilt (turbo run buildfiltered to core, cli, plugin-email, service-sms and verify):Tasks: 59 successful, 59 total.check:generated. Before regeneration, it found exactly two stale artifacts:api-surface/("0 breaking (removed/narrowed), 1 added",+ IWebhookService (interface)) andexport-origins/. Aftergen:api-surfaceandgen:export-origins, at06119a91ff: "All 14 generated artifacts are up to date".06119a91ff.pnpm --filter @objectstack/spec typecheck(tsc --noEmit,check:scripts-typecheckandcheck:test-typecheck):VERDICT command-exit 0.check:test-typecheckreports OK.tsc -p tsconfig.test.json --listFileslists bothwebhook-service.tsandwebhook-service.test.ts, with 0 diagnostics in either.06119a91ff:pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/webhook-service.test.ts src/contracts/approval-service.test.ts src/contracts/core-service-contracts.test.tsgaveTest Files 3 passed (3),Tests 19 passed (19).bbac3326b9, the whole spec suite (vitest run --project local --maxWorkers=2) gaveTest Files 644 passed (644),Tests 19213 passed | 1 todo. Since then the only commit is the second merge, which touches no spec file.packages/specimports the new contract (git grep exit 1), and the change emits no JavaScript.06119a91ff.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; change set derived from the merge base7098acaef9, 6 paths, 164 changed lines) derived 87 commands, and all 87 exited 0. They includecheck:api-surface("public API surface + factory signatures unchanged"),check:export-origins,check:generated,check:docs,check:authorable-surface,check:exported-any,check:spec-docblock-symbol-anchors,check:adr-0087-registration("this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"),check:changeset-no-major,check:empty-changeset,check:doc-authoring,check:issue-citations("every citation this change adds resolves", 5 of 5),check:nul-bytes,check:dual-build-cjs-loadsandcheck:lean-entry-closure.--ranreconciliation, on a record carrying each exit code: "87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED".minorwith the(widening)arm, which the gate reads as non-breaking, so no disposition marker is owed and none is written.740277750dthroughscripts/ablation-replace.mjs(wrap mode, plus an outertraprestore on EXIT, INT and TERM).handleRedeliver?(request: Request)becamehandleRedeliver(request: Request). On disk the anchor went 1 → 0, the replacement 0 → 1, and the blob3ed2ead393de→28539f95c0f1../webhook-service), so the type program readssrc, and no dist rebuild was involved.webhook-service.test.ts(30,42): error TS2344: Type 'false' does not satisfy the constraint 'true'(RedeliverIsOptional) and(46,15): error TS2741: Property 'handleRedeliver' is missing(the value pin).3ed2ead393de, equal to the HEAD blob, andgit diff HEADis empty.Acceptance notes
ServiceSlotContractsgains nowebhooksentry here. That ledger says an entry "is only made where the binding is evidenced — by the provider that registers the slot, or by dispatcher work that already proved the correspondence", and nothing registerswebhooksuntil the plugin segment. That segment, or the first one to type the lookup, addswebhooks: IWebhookService(the approvals precedent has no entry either).registerAdminRoutesmounts the self-hosted route whenever thehttp-serverslot and the messaging service are both present. The webhook veto is installed only insidebootAutoEnqueue, which returns early whenautoEnqueueisfalseor realtime is absent. On those compositions, the self-hosted door runs without the veto. Whether a webhook-sourced row can exist there was not measured. The plugin segment edits exactly these functions, and the slot registration it adds should sit beside the veto, not beside the raw mount. Carrier: Sweep (ruling A-2 of #22438): plugin-webhooks' redeliver endpoint and trigger-api's inbound hooks endpoint mount only on http.server's raw app; measure each on a dispatcher-only kernel and bridge each 404 the way #22438 is bridged #22564's plugin segment.@objectstack/honocatch-all already leaves the raw request readable for every body (it parses a clone,packages/adapters/hono/src/index.ts), so the domain can forward the JSON body unread with no adapter change. Itsapprovals.tssibling is the template: an exact route,POSTonly, and501on an empty slot or absent member.check:api-surfacerecords the new interface's name and kind, not its members, so the snapshot showsIWebhookService (interface)only.dispatch-gatesartifact-roster block (50 families), the declared wide-population families, the CI type-check lanes (workspace turbo typecheck,./examples/*,downstream-contract) and the path-scheduled jobs (Test Core, Dogfood, Build Core, Temporal Conformance) were not run. Three PR-context guards (check-closing-target-claim,check-partof-closing-keyword,check-single-claim-paths) need this PR to exist and were not run here.pr_createand one report comment, and it names no label. This diff carries a changeset, soskip-changesetdoes not apply.Generated by Claude Code