Repository navigation
Commit e84aeb3
feat(spec): declare IWebhookService.handleRedeliver, the optional transport-neutral webhook redeliver member (#22767)
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 for `POST /api/v1/webhooks/redeliver`. On a kernel with no
`http.server` (the hosted shape), that endpoint answers `404
ROUTE_NOT_FOUND` today (stage-1 measurement `6093135788`, accepted at
`6093165613`).
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 contract
`IWebhookService` for the `webhooks` service slot. It has one optional
member, `handleRedeliver?(request: Request)`, which returns a `Promise`
of a web-standard `Response`. Its docblock states five things:
- **What it serves.** The same answers as the plugin's self-hosted
route: `401 UNAUTHENTICATED` with no signed-in user; `400
INVALID_REQUEST` for a non-JSON body; `400 MISSING_REQUIRED_FIELD`
without a usable `deliveryId`; `404 RESOURCE_NOT_FOUND` for a row
outside the caller's active organization; `409` with
`DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses
the replay; `500 INTERNAL_ERROR` otherwise; and `200` with the row's id
and new status.
- **Authentication and tenant.** It resolves the caller from the
request's own credentials, through the kernel's `auth` service, 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 no `ExecutionContext`.
- **Transport neutrality.** There is no Hono, raw-app or `http.server`
type in the signature.
- **Its one caller.** That is the runtime `HttpDispatcher`'s exact `POST
/webhooks/redeliver` domain, 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.
- **The absence rule.** If the `webhooks` slot or the member is absent,
the caller answers a typed 404 or 501, never `ROUTE_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.
- `RedeliverIsOptional` uses the `{} extends Pick` form, because
`toEqualTypeOf` cannot tell an optional member from a required one typed
with `undefined`.
- `RedeliverTakesOneRequest` asserts the parameters are exactly one
`Request`.
- `RedeliverAnswersAResponse` asserts the return type is a `Promise` of
`Response`.
- `BarrelExportsTheContract` asserts the barrel exports this same
contract.
- The value-level pin is that an empty object typed as `IWebhookService`
compiles and carries no member.
- No docblock prose is pinned, because no consumer parses it.
- `packages/spec/api-surface/contracts.json` and
`packages/spec/export-origins/contracts.json`: one regenerated line
each, for the new export.
- `.changeset/22754-spec-webhooks-redeliver-member.md`:
`@objectstack/spec` `minor` (pre mode `next`), with its own `Clause-②:
yes (widening)` line.
## The slot: a new `webhooks` contract, not the messaging service's
The 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 `redeliverHttp` lives there. **This PR departs from that
suggestion, for the reasons below.**
### What was measured first
1. **Confirmed.** plugin-webhooks registers exactly one service,
`webhook.autoEnqueuer` (`webhook-outbox-plugin.ts:317`). `redeliverHttp`
has 0 hits in `packages/spec/src` (git grep exit 1). The messaging
service registers `messaging` and `notification`
(`messaging-service-plugin.ts:213`, `:222`). The candidate slot keys are
therefore `webhooks` (the plugin's platform capability token,
`platform-capabilities.ts`; registered by nothing yet) and `messaging`.
2. **Split, so the premise does not hold as stated.** The messaging
service owns the replay OPERATION: `MessagingService.redeliverHttp(id, {
tenantId })` resets a finished row to `pending`, applies the tenant wall
and the parked-row refusal, consults each producer's veto, and wakes the
dispatcher. plugin-webhooks owns the DOOR:
- it requires a signed-in user;
- it resolves the tenant from the session's active organization;
- it reads the JSON body and maps each refusal code to a status;
- it installs the veto that refuses to replay a webhook row whose
subscription or signing secret is gone (`installRedeliverGuard`, called
before the auto-enqueuer writes its first row).
One more finding: **there is no messaging service contract to declare
on.** `ServiceSlotContracts` has no `messaging` entry, and no
`IMessagingService` exists. The messaging route would mint a new
contract file too, so both options cost one new contract.
3. **Read.** The handler (`registerAdminRoutes`) takes the session's
`user.id` and `session.activeOrganizationId` and a JSON body `{
deliveryId }`. It answers the envelope listed above. The docblock
describes that behaviour, and the plugin is unchanged.
### The four axes
- **Real business need.** The measured need is the door on a hosted
kernel that composes plugin-webhooks. On the `webhooks` slot, the door
exists exactly where self-hosted has it. A door on the `messaging` slot
would also exist on a kernel without plugin-webhooks. Nobody asked for
that door, and the webhook veto is never installed there.
- **Long-term soundness.** The ruling's shape is "a dispatcher domain to
the service slot", which forwards and does not reimplement; `/auth` and
`/approvals/act` are the precedents (ADR-0076 D11). Under this choice,
plugin-webhooks keeps the door's rules in one place for both kernel
shapes. On the `messaging` slot, the runtime domain would hold a second
copy of the authentication, body parsing and refusal-to-status mapping,
beside the plugin's raw mount.
- **Preventing AI mistakes.** There is one copy of the door's rules, so
a later edit cannot drift one shape from the other. The absence rule is
a typed 501 from an empty slot. The tenant requirement stays where it is
already enforced: plugin-webhooks' typed view of `redeliverHttp` makes
its `tenantId` a required property typed as string-or-undefined, so a
caller must decide it. The messaging route's real advantage was a typed
`tenantId` at the contract. It is not lost here; it stays at the one
place that calls the operation.
- **Startup-stage focus.** Each option mints one contract with one
optional member. This one adds a slot key that the plugin registers in
its own segment, the same window `handleActionPage` had 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.autoEnqueuer` is registered
only when auto-enqueue starts.
**The precedent is kept, not departed from.** The #22575 member shape (a
web-standard `Request` in, `Response` out), its docblock structure, its
pin form and its changeset level are all reused.
### Why `handleRedeliver`
- The verb `handle…` is this package's spelling for a function from a
`Request` to a `Response`: `IAuthService.handleRequest`,
`IRealtimeService.handleUpgrade?` and
`IApprovalService.handleActionPage?`.
- `Redeliver` is the route's own last segment and the plugin's own word
("redeliver endpoint", `registerRedeliverGuard`).
- `IWebhookService` / `handleRedeliver` had 0 hits anywhere outside this
diff before it: objectstack git grep exit 1 (control `IApprovalService`
exit 0), and objectui at `023f00d46` exit 1 (control `IAuthService`: 2
hits).
## Verification
Heavy runs went through `scripts/pm/os-verify-lock.sh`. The `VERDICT`
line is quoted from each. The final head is `06119a91ff`: two merges of
`origin/main` (`e86530088a`, then `7098acaef9`) on top of the two change
commits. The second merge touched no `packages/spec` file.
- **Build.** `pnpm --filter @objectstack/spec build` at `740277750d`,
and again after the first merge at `bbac3326b9`: `VERDICT command-exit
0` both times. The build wrote no tracked file. `handleRedeliver` has 1
hit each in `dist/contracts/index.d.ts` and `.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 build`
filtered 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)`) and `export-origins/`. After
`gen:api-surface` and `gen:export-origins`, at `06119a91ff`: "All 14
generated artifacts are up to date".
- **Typecheck at `06119a91ff`.** `pnpm --filter @objectstack/spec
typecheck` (`tsc --noEmit`, `check:scripts-typecheck` and
`check:test-typecheck`): `VERDICT command-exit 0`.
`check:test-typecheck` reports OK. `tsc -p tsconfig.test.json
--listFiles` lists both `webhook-service.ts` and
`webhook-service.test.ts`, with 0 diagnostics in either.
- **Tests.**
- At `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.ts` gave `Test Files 3 passed
(3)`, `Tests 19 passed (19)`.
- At `bbac3326b9`, the whole spec suite (`vitest run --project local
--maxWorkers=2`) gave `Test Files 644 passed (644)`, `Tests 19213 passed
| 1 todo`. Since then the only commit is the second merge, which touches
no spec file.
- Consumers' tests were not run. Nothing outside `packages/spec` imports
the new contract (git grep exit 1), and the change emits no JavaScript.
- **Gates at `06119a91ff`.**
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; change set derived from the merge
base `7098acaef9`, 6 paths, 164 changed lines) derived 87 commands, and
all 87 exited 0. They include `check: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-loads` and `check:lean-entry-closure`.
- `--ran` reconciliation, on a record carrying each exit code: "87
derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED".
- The ADR-0087 disposition: the changeset is `minor` with the
`(widening)` arm, which the gate reads as non-breaking, so no
disposition marker is owed and none is written.
- **Reverse verification**, run from the committed `740277750d` through
`scripts/ablation-replace.mjs` (wrap mode, plus an outer `trap` restore
on EXIT, INT and TERM).
- The mutation made the member required: `handleRedeliver?(request:
Request)` became `handleRedeliver(request: Request)`. On disk the anchor
went 1 → 0, the replacement 0 → 1, and the blob `3ed2ead393de` →
`28539f95c0f1`.
- The subject is imported relatively by the test (`./webhook-service`),
so the type program reads `src`, and no dist rebuild was involved.
- **Expected direction: red. Observed: red.** spec's test program
reported `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).
- No consumer outside spec could turn red, because nothing implements
the contract yet.
- **Restore leg.** The blob after restore is `3ed2ead393de`, equal to
the HEAD blob, and `git diff HEAD` is empty.
## Acceptance notes
- **`ServiceSlotContracts` gains no `webhooks` entry 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 registers `webhooks` until the
plugin segment. That segment, or the first one to type the lookup, adds
`webhooks: IWebhookService` (the approvals precedent has no entry
either).
- **For the plugin segment (read only, not filed).**
`registerAdminRoutes` mounts the self-hosted route whenever the
`http-server` slot and the messaging service are both present. The
webhook veto is installed only inside `bootAutoEnqueue`, which returns
early when `autoEnqueue` is `false` or 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: #22564's plugin
segment.
- **For the dispatcher segment.** The `@objectstack/hono` catch-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. Its `approvals.ts` sibling
is the template: an exact route, `POST` only, and `501` on an empty slot
or absent member.
- `check:api-surface` records the new interface's name and kind, not its
members, so the snapshot shows `IWebhookService (interface)` only.
- **Not run locally; these are CI's.** The `dispatch-gates`
artifact-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.
- **Labels: none written.** The dispatch's write budget was a push, one
draft `pr_create` and one report comment, and it names no label. This
diff carries a changeset, so `skip-changeset` does not apply.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 896a434 commit e84aeb3
6 files changed
Lines changed: 164 additions & 0 deletions
File tree
- .changeset
- packages/spec
- api-surface
- export-origins
- src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| 166 | + | |
166 | 167 | | |
167 | 168 | | |
168 | 169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| 166 | + | |
166 | 167 | | |
167 | 168 | | |
168 | 169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
| 63 | + | |
63 | 64 | | |
64 | 65 | | |
65 | 66 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
0 commit comments