Repository navigation
Commit 5fc57b3
feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member (#22779)
Fixes #22772
Clause-②: yes (widening)
## What this does
Segment 1 of 3 of ruling A on #22757 (director record `6105447950`,
maintainer 「同意」). It declares the forward target that a dispatcher
domain will call for the inbound hook, `POST
/api/v1/automation/hooks/:flowName/:hookId`. On a kernel with no
`http.server` (the hosted shape), a correctly signed anonymous sender
gets `401 UNAUTHENTICATED` from the `/automation` anonymous floor today
and never reaches the trigger's HMAC check.
Nothing implements or calls the member yet. Segment 2 (#22773, the
runtime domain and the parameterised allow-list row) and segment 3
(#22774, the trigger-api implementation) read this shape.
- `packages/spec/src/contracts/trigger-api-service.ts` (new): the
contract `ITriggerApiService` for the **`trigger-api`** service slot,
which `@objectstack/trigger-api`'s `ApiTriggerPlugin` will register. It
has one optional member:
`handleInboundHook?(request: Request, hook: { readonly flowName: string;
readonly hookId: string })`, returning a Promise of a web-standard
`Response`.
Its docblock names the slot key in prose, as `IWebhookService`'s does,
and states:
- **What it serves.** The trigger's answers, in the handler's order:
`404 RESOURCE_NOT_FOUND` for an unknown flow or a wrong hook id
(identical; the hook is matched before the signature is verified, so the
hook id, not the flow name, keeps an armed flow unprobeable, and a flow
left on the default hook id can be probed: a uniform refusal is #22769's
question); `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be
read; `401 INVALID_SIGNATURE` for a missing or wrong
`x-objectstack-signature`; `400 INVALID_REQUEST` for a verified body
that is not valid JSON or not a JSON object; `202 { accepted: true,
messageId }` once enqueued, with the flow running on the queue consumer
and never in-band (ADR-0041 §5); `503 SERVICE_UNAVAILABLE` with no
queue; and `503 ENQUEUE_FAILED` when the publish throws.
- **The member is the boundary (ruling A, condition 2).** The
implementer verifies inside the member, through the same verifier as the
self-hosted mount: a per-hook secret, a constant-time comparison, and
verification before anything is enqueued. It reads the raw body from the
request itself. The caller never reads, parses, re-serialises,
pre-checks or short-circuits it.
- **The signature is the only credential.** The member takes no
`ExecutionContext` and reads no session, cookie or `Authorization`
header.
- **Transport neutrality.** There is no Hono, raw-app or `http.server`
type in the signature.
- **The path parameters arrive as `hook`**, as the caller's route
matched and decoded them. The member never re-parses the URL.
- **Its one caller.** That is the runtime `HttpDispatcher`'s exact `POST
/automation/hooks/:flowName/:hookId` domain. It forwards the body unread
and answers a body the transport already consumed as a server fault. The
raw-app mount is not a caller, and the path is never mounted twice.
- **The absence rule.** An empty `trigger-api` slot or an absent member
answers a typed 404 or 501, never `ROUTE_NOT_FOUND`.
- **Replay protection is not here** (ruling A condition 3, which #22769
covers). The member has no timestamp or nonce field. Anything a later
scheme signs arrives in the same forwarded request, so the signature
does not grow for it.
- `packages/spec/src/contracts/index.ts`: exports the contract from
`@objectstack/spec/contracts`, placed beside `automation-service.js`.
- `packages/spec/src/contracts/trigger-api-service.test.ts`: the
type-level pin (details under Verification).
- `packages/spec/api-surface/contracts.json` and
`packages/spec/export-origins/contracts.json`: one regenerated line
each, for the new export.
- `.changeset/22772-spec-trigger-api-inbound-hook-member.md`:
`@objectstack/spec` `minor`, with its own `Clause-②: yes (widening)`
line.
No runtime, core or plugin code changed. The signature check stays in
the trigger.
## The slot: a new `trigger-api` contract, not `IAutomationService`
### What was measured first (at `origin/main` `996aa86e0a`, re-read at
the merged head)
1. **Confirmed.** `ApiTriggerPlugin` registers no service of its own. At
`kernel:ready` it calls `automation.registerTrigger(trigger)`
(`packages/triggers/trigger-api/src/plugin.ts:75`), typed only by a
structural `AutomationTriggerRegistry` slice (`:12`). It mounts
`HOOKS_PATH` on the `http-server` slot's raw app only (`:77`–`:93`). The
service keys a dispatcher domain would look up are:
- (a) `trigger-api`, for a new contract. Nothing registers it yet: 0
hits for a `registerService` / `getService` / `resolveService` of
`'trigger-api'` under `packages/` (git grep exit 1).
- (b) `automation`, for `IAutomationService`. It is registered by
`service-automation`
(`packages/services/service-automation/src/plugin.ts:975`) on every
kernel that composes automation, with or without trigger-api.
2. **The automation service implements none of the door, so (b) would be
declared but unenforced.** The engine's `registerTrigger`
(`engine.ts:3592`) stores the `FlowTrigger` by type and activates bound
flows. For an `api` flow it builds a binding with `config` and, when the
flow has a secret to read, a `resolveSecret` reader
(`engine.ts:3788`–`3800`). Its own comment there reads: "The concrete
trigger (`@objectstack/trigger-api`) mounts the endpoint and enqueues".
`FlowTrigger` has no request member (`engine.ts:633`), and
`registerTrigger` is not on `IAutomationService` at all. Every step of
the door is `ApiTrigger.handleRequest` (`api-trigger.ts:218`–`275`): the
hook lookup with `safeEqual`, the secret read, `verifySignature`, the
JSON parse, the queue publish and the 202. So the #22754 reasoning
applies: the door's owner registers the slot.
3. **Read.** The handler's input is `{ flowName, hookId, rawBody,
signatureHeader, idempotencyKey }`. The raw mount fills them from
`c.req.param(...)`, `c.req.text()` and the two headers
(`plugin.ts:84`–`90`). Its output is `{ status, body }`, served by
`c.json(out.body, out.status)`. The status and code list in the docblock
is the handler's, line by line, including two details the dispatch's
list did not have: the secret-unavailable 503 carries
`SERVICE_UNAVAILABLE`, and an empty or whitespace-only body parses as
the empty object. The three trigger-api codes (`ENQUEUE_FAILED`,
`INVALID_REQUEST`, `INVALID_SIGNATURE`) are already in the error-code
ledger under `@objectstack/trigger-api`.
### The four axes (slot)
- **Real business need.** The measured need is the door on a hosted
kernel that composes trigger-api, which is what ruling A grants. On the
`trigger-api` slot, the door exists exactly where the trigger is
composed. An `automation`-slot member would exist on every automation
kernel, including one with no inbound-hook trigger, where no hook can
ever be armed.
- **Long-term soundness.** One route, one owner. The trigger keeps the
door's whole rule set (lookup, secret, verifier, parse, enqueue,
answers) in one place for both kernel shapes, and the dispatcher only
forwards, as `/auth`, `/approvals/act` and `/webhooks/redeliver` do.
Route (b) would need a new request member on `FlowTrigger`, an
engine-side forwarding hop in `service-automation`, and a widened
`IAutomationService`. That is a second package carrying the door, a
package segment 3's card does not name.
- **Preventing AI mistakes.** Declared means enforced: the member sits
on the contract of the package that implements it. Route (b) would put
"accepts and verifies a signed hook" on the flow engine's contract, so a
reader of `IAutomationService` would place the verifier in the engine.
That is exactly the boundary confusion that condition 2 exists to
prevent.
- **Startup-stage focus.** One new contract with one optional member (+1
export, type-only), the same cost #22754 paid. Route (b) widens a
1,278-line contract and adds runtime code in a lane this card does not
own.
**The slot key.** It is `trigger-api`, the package's own name. ADR-0041
§2 names the trigger family `trigger-*`, and the ruling and the three
segment cards all call this door's owner "trigger-api", and the
error-code ledger keys its codes by that package. Kebab-case is the
majority spelling among the registry's multi-word slot keys
(`http-server`, `file-storage`, `org-scoping`, `datasource-admin`). The
contract name follows the slot, as `IWebhookService` follows `webhooks`.
No platform capability token was used, because trigger-api has none of
its own: it is an extra of `triggers`, whose token maps to
`trigger-record-change`.
**`ServiceSlotContracts` gains no entry**, for the reason the ledger
itself states: an entry is made only where a provider registers the
slot. Segment 3 adds `'trigger-api': ITriggerApiService` beside its
registration. The `approvals` and `webhooks` precedents did the same.
## The member shape: `(request, hook)` returning a Promise of Response
The dispatch allowed either a Request/Response member or a typed
input/output pair. This PR takes the first, with the path parameters as
a second argument.
- **Real business need.** Measured inputs: the raw body,
`x-objectstack-signature`, `x-idempotency-key`, and the two path
parameters. A `Request` carries the first three, and the hook's address
carries the other two. A typed pair would need fields for each, which is
the card's "the headers" spelled as a fixed list.
- **Long-term soundness.** It has the same forwarding shape as
`handleActionPage` and `handleRedeliver`: the dispatcher forwards the
request unread and returns the member's `Response` as it is. A typed `{
status, body }` result would make the dispatcher assemble the answer,
which is a second place the trigger's answer is spelled. A later signing
scheme (#22769) arrives inside the request, so the contract does not
grow fields for it.
- **Preventing AI mistakes.** This is the decisive axis.
- With a `Request` in, the caller has nothing to hand over but the
unread body, so it structurally cannot parse and re-serialise the body
before verification. A typed `rawBody` would make the caller read the
body, and parse-then-serialise is the natural way for that to go wrong:
it fails verification for every real sender.
- The second argument comes from the router that matched the route. A
member that re-parsed the URL would meet the API prefix and an
`/environments/:id` scope on the dispatcher shape (the dispatcher strips
both before `DomainRequest.path`;
`packages/runtime/src/domain-handler-registry.ts`), and could address a
different hook than the four-segment allow-list row admitted.
- The pin holds the address to exactly `flowName` and `hookId`, so
moving the body, the signature or a timestamp out to the caller turns
the pin red.
- **Startup-stage focus.** One interface export. There are no input or
output types to publish.
**Against the two precedents.** Both took `(request: Request)` alone,
because their doors carry nothing in the path: the action-page token
rides in the query or form, and the `deliveryId` rides in the body. This
door is the first whose address is in the path, so the one departure is
the second argument. Everything else is kept: the docblock skeleton, the
pin form, the barrel-identity pin and the changeset level. **Why
`handleInboundHook`:** `handle…` is this package's spelling for a
`Request`-to-`Response` member (`handleRequest`, `handleActionPage`,
`handleRedeliver`), and "inbound" keeps it apart from ObjectQL data
hooks and kernel lifecycle hooks. `ITriggerApiService` and
`handleInboundHook` had 0 hits before this diff, in objectstack (git
grep exit 1) and in objectui at `023f00d46` (exit 1; control
`IAuthService`: 2 hits).
## Verification
Heavy runs went through `scripts/pm/os-verify-lock.sh`, and the
`VERDICT` line is quoted from each. The readings below were taken at the
build head `6c636dca6e`, a merge of `origin/main` at `e84aeb36ce` (which
carries #22767) on top of the change, followed by regeneration. Patch
round 1 (`ad45100733`, the docblock sentence on probing, text only)
re-ran the spec build, `check:generated`, typecheck, suite and gates
with the same results; its readings are in the round-1 `os-dev-report`
on #22772.
- **The merge and regeneration.** `scripts/pm/os-regen-merge.sh` merged
`origin/main`. The os-regen driver kept this branch's side of both
`contracts.json` shards, dropping main's `IWebhookService` line: 0
occurrences after the driver, 1 after regeneration. Regeneration on the
merged tree uses `pnpm --filter @objectstack/spec build` (`VERDICT
command-exit 0`), then `gen:api-surface` and `gen:export-origins`. It
restored both lines, and the net delta against `origin/main` is exactly
one added line per shard (`ITriggerApiService`).
- **Type-only.** In `packages/spec/dist`, `handleInboundHook` has 2 hits
(`contracts/index.d.ts`, `.d.mts`) and 0 in any emitted `.js` / `.mjs` /
`.cjs`.
- **Tests at `6c636dca6e`.** The whole spec suite, `pnpm --filter
@objectstack/spec exec vitest run --project local --maxWorkers=2`, gave
`Test Files 646 passed (646)`, `Tests 19223 passed | 1 todo (19224)`,
`VERDICT command-exit 0`.
- **Typecheck at `6c636dca6e`.** `pnpm --filter @objectstack/spec
typecheck` (`tsc --noEmit`, `check:scripts-typecheck`,
`check:test-typecheck`) gave `VERDICT command-exit 0`, with
`check:test-typecheck: OK` (246 errors held, equal to the debt ledger).
`tsc -p tsconfig.test.json --listFiles` lists both
`trigger-api-service.ts` and `trigger-api-service.test.ts`, with 0
diagnostics in either.
- **The pins** (`trigger-api-service.test.ts`), exported type aliases
compiled by `check:test-typecheck`:
- `InboundHookIsOptional`: the `{} extends Pick` form.
- `InboundHookTakesTheRequestAndTheAddress`: the parameters are exactly
`[Request, { readonly flowName; readonly hookId }]`, with no execution
context.
- `HookAddressCarriesOnlyThePathParameters`: the address keys are
exactly `flowName` and `hookId`.
- `InboundHookAnswersAResponse`: the return is a Promise of `Response`.
- `BarrelExportsTheContract`.
- A value pin: an empty object typed as the contract compiles and
carries no member.
- No docblock prose is pinned, because no consumer parses it.
- **Reverse verification**, run from the committed `476fd79e40` through
`scripts/ablation-replace.mjs` in wrap mode. The subject is imported
relatively, so the type program reads `src` and no dist rebuild is
involved. The baseline is 0 errors in the test file (246 total, all
ledgered). The merge changed nothing in either file.
- **A, the member made required** (`handleInboundHook?(` became
`handleInboundHook(`). The anchor went 1 → 0 and the blob `b44b329d9b6a`
→ `f61056dc7c08`. Expected red, observed red:
`trigger-api-service.test.ts(36,44): error TS2344`
(`InboundHookIsOptional`) and `(57,15): error TS2741: Property
'handleInboundHook' is missing`.
- **B, a body field added to the address** (`readonly rawBody: string`).
The anchor went 1 → 0 and the blob `b44b329d9b6a` → `d4254ab8327f`.
Expected red, observed red: `(40,5): error TS2344`
(`InboundHookTakesTheRequestAndTheAddress`) and `(44,62): error TS2344`
(`HookAddressCarriesOnlyThePathParameters`).
- **Restore legs.** The blob after restore is `b44b329d9b6a`, equal to
the HEAD blob, and `git diff HEAD` is empty, both times.
- **Gates at `6c636dca6e`.** `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths; merge base
`e84aeb36c`; 6 paths, 215 changed lines) derived 87 commands.
- 86 exited 0. They include `check:api-surface` ("public API surface +
factory signatures unchanged"), `check:generated` ("All 14 generated
artifacts are up to date"), `check:export-origins`, `check:docs`,
`check:authorable-surface`, `check:exported-any`,
`check:dual-source-exports`, `check:entry-nameability`,
`check-spec-docblock-symbol-anchors`, `check:slot-lookup`,
`check:doc-authoring`, `check-issue-citations` ("every citation this
change adds resolves", 3 of 3), `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:lean-entry-closure`,
`check:type-check-debt`, and `check-adr-0087-registration` ("this PR
adds no declared-breaking changeset (1 non-breaking changeset(s)
seen)").
- `check:doc-formula-expressions` and `check:lean-entry-closure` first
refused with exit 3 (an unbuilt prerequisite, not a measurement). They
were re-run green after `turbo run build --concurrency=2` of formula,
lint and objectql (`Tasks: 14 successful`).
- **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it needs a
full workspace build (88 packages without `dist/`), and this diff emits
no JavaScript. CI's `Lint & Repo Gates` runs it.
- `--ran` reconciliation: "87 derived famil(ies) accounted for — 86 run,
1 NOT-MEASURED".
- ADR-0087: 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.
## Acceptance notes
- **For segments 2 and 3.** The slot key is `trigger-api` and the member
is `handleInboundHook(request, { flowName, hookId })`, both named in the
docblock. Segment 3 registers the slot in `ApiTriggerPlugin` and adds
the `ServiceSlotContracts` entry beside the registration. Segment 2
resolves the same key and hands over the router's decoded parameters.
- **Read only, not filed.** The trigger registry the plugin calls
(`registerTrigger` / `unregisterTrigger`) is not on
`IAutomationService`. Each trigger plugin declares a structural slice of
its own instead (`plugin.ts:12`). This is an undeclared cross-package
seam with no measured wrong answer. Carrier: none.
- **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 and the path-scheduled jobs (Test
Core, Dogfood, Build Core, Temporal Conformance) were not run. Three
PR-context guards need this PR to exist and were not run here:
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`.
- **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 179f7bf commit 5fc57b3
6 files changed
Lines changed: 221 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 | + | |
| 15 | + | |
| 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 | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
34 | 37 | | |
35 | 38 | | |
36 | 39 | | |
| |||
| 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 | + | |
| 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 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
0 commit comments