Skip to content

feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member - #22779

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22772-inbound-hook-member
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22772-inbound-hook-member

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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 trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #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 trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #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 spec: declare the forward target for POST /api/v1/webhooks/redeliver — an optional, transport-neutral service member a dispatcher domain can reach (webhooks segment 1 of #22564's stage 2) #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 spec: declare the forward target for POST /api/v1/webhooks/redeliver — an optional, transport-neutral service member a dispatcher domain can reach (webhooks segment 1 of #22564's stage 2) #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 (trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #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

…l transport-neutral inbound-hook member

A new contract for the `trigger-api` slot that @objectstack/trigger-api
fills, with one optional member that serves
POST /api/v1/automation/hooks/:flowName/:hookId from a web-standard
Request plus the two matched path parameters, answering the trigger's own
Response. The verifier stays inside the member (ruling A, condition 2):
the caller forwards the body unread.

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
…tree

The os-regen driver kept one side of both contracts.json shards; the
generators re-derive them from the merged source, carrying both
IWebhookService (from main) and ITriggerApiService.

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json, packages/spec/src/contracts/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/environment-routing.mdx (via /environments/:id (route, a path literal in a comment in ITriggerApiService))
  • content/docs/concepts/north-star.mdx (via /environments/:id (route, a path literal in a comment in ITriggerApiService))
  • content/docs/protocol/kernel/metadata-service.mdx (via /environments/:id (route, a path literal in a comment in ITriggerApiService))

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

  • content/docs/releases/implementation-status.mdx (via /environments/:id (route, a path literal in a comment in ITriggerApiService))

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
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json, packages/spec/src/contracts/index.ts) — pages documenting those are invisible to this run
  • 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 — 139 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 a2e94c2a053ab004b5cb9fea27824bb614e5759c → packageMentionDocs.

Which tree this was computed on

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

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

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

…es not hide

An unknown flow and a wrong hook id answer one 404, but the hook is
matched before the signature is verified, so the hook id (not the flow
name) is what keeps an armed flow unprobeable, and a flow on the default
hook id can be probed. A uniform refusal is the hardening card's question.

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Delta review against the held record on 6c636dca6e. Inputs read: that record and everything it read; the seat order 6106448417 and the round-1 os-dev-report 6106751828 on #22772; the PR body as stored after both body patches (18095 bytes, read back) diffed line by line against the body the first record read; the one commit 6c636dca6e..ad45100733 (packages/spec/src/contracts/trigger-api-service.ts, +8/−2, one hunk) and the net diff origin/main...ad45100733 (6 files, +221/−0, merge-base still e84aeb36ce); every check-run on the head. Code claims are read at the head, never from the PR text.

① Derived judgments

  • Carried forward, re-verified at this head. The delta changes no code and no file the first record's judgments rest on: api-trigger.ts, plugin.ts, auth-gate.ts, domains/approvals.ts, http-dispatcher.ts and domain-handler-registry.ts are byte-identical between the two heads, and none of the 8 commits origin/main gained since e84aeb36ce touches them or any of the PR's 6 paths (the one src/contracts touch on main, approval-service.ts via feat(spec,plugin-approvals,rest): an unstaffed arm on the approvals inbox read returns an admin_rescue request to a caller who may override it #22775, is outside this diff, and the two contracts.json shards on main are unchanged). So these stand as written on 6c636dca6e: +1 export, type-only, both shards +1 with IWebhookService intact; the member's shape and the accept-set with its six pins (the test file is unchanged, and docblock prose is pinned by nothing); the trigger-api slot call, the key and the declared-before-registered window with no ServiceSlotContracts entry; the second hook argument; ruling A condition 2 on all six points and the consumed-body server fault; condition 3 with nothing pre-committed; the absence rule; the status list in the handler's order; shape parity; the claim surface; the test import spelling. Right.
  • The rewritten first bullet, each claim against api-trigger.ts at the head. (i) An unknown flow and a wrong hook id answer one 404 RESOURCE_NOT_FOUND: one branch, !hook || !safeEqual(hook.hookId, input.hookId) (:225–230). True. (ii) The hook is matched before the signature is verified: that branch precedes the secret read (:234–246) and verifySignature (:247). True. (iii) An unsigned post naming an armed flow and its hook id gets the 503 (secret unavailable, :244) or the 401 (:248) rather than the 404: both sit after the match and before anything else. True, and naming the 503 beside the 401 is what makes it complete. (iv) The hook id, not the flow name, keeps an armed flow unprobeable: with the id unknown every probe is the same 404 whether or not the flow exists; with it known the 503/401 versus 404 split tells. True. (v) The default id default applies when config.hookId names none: cfg.hookId not a non-blank string → 'default' (:159). True. (vi) "Whether every unverified post should get one uniform refusal is the hardening card's question (trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #22769), not this contract's": it commits nothing on the member (no field, no order, no answer moved) and reads the status list as what the trigger serves today; it is a disposition of the question, not a design. True. The sentence is now true and complete, and it pre-commits nothing for trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #22769.
  • No other line still implies "no oracle". oracle has 0 hits in the net diff; the file's other probe lines (:31, :124) are the caller probing for the member's presence; the changeset's "an unknown flow or a wrong hook id answers 404 RESOURCE_NOT_FOUND, identically" states the equality alone, which still holds, so no changeset line was owed and none was changed. The same overclaim remains in api-trigger.ts's own comment (:226–227), which is segment 3's file and not this PR's, as the order says. Right.
  • The seat order honoured. One sentence in the one named file, text only: one hunk, +8/−2; no other contract text, no code, no changeset line; one body patch for the sentence; one report. origin/main moved under none of the PR's paths, so no merge was owed and none was made. Right.
  • The PR body. The only content change is line 13: the parenthesis now reads "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 trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #22769's question", the docblock's sentence in short. The footer is restored in the session-URL form and there is exactly one Generated by line (18095 bytes stored). One line is now false: the Verification section's "The final head is 6c636dca6e" (and its "215 changed lines"); the readings the body quotes at 6c636dca6e are still readings taken at that head, and the round-1 report carries the ad45100733 readings (build, check:generated with both shard lines, typecheck with the 246-error ledger, suite 646/19223, 86 of 87 gate families, check:dual-build-cjs-loads left to CI). The order allowed no other body change, so the stale line is the seat's to decide, carried in ③; it does not touch what the PR publishes.

② Semver level

  • Unchanged by the delta and still right: @objectstack/spec minor, additive and type-only (api-surface 1 added, 0 breaking; check:api-surface "unchanged" on the head); the changeset carries its own Clause-②: yes (widening) line, matching the PR body; no ADR-0087 marker owed for a non-breaking changeset; not skip-changeset. Check Changeset is green on the head, three times over (re-fired by each body edit).
  • Clause-②: yes (widening)

③ Boundary flags

Gates on ad451007333980b6df9668828b40beffaeb0b18d (the required contexts on main are TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard): every required context completed green, read live from the head's check-runs after the last run concluded (07:42Z): TypeScript Type Check (four lanes and the aggregate), Test Core (six shards and the aggregate), Dogfood Regression Gate (three shards and the aggregate), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard. 49 check-runs in all: 42 success, 7 skipped; 35 are the push run (32 success; Build Docs, Console Pin Gate and Packed-tarball smoke skipped by path) and 14 are the two pull_request edited re-fires at 07:37Z and 07:39Z, one per body patch, which re-ran the seven PR-event guards (Check Changeset and the four claim guards green both times; Auto Label and Check PR Size skip on an edited event). Zero failures and zero cancellations anywhere; the branch tip is this head; mergeable_state is clean.

Implemented-by: claude/issue-22772-inbound-hook-member
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 07:45
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 07:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 5fc57b3 Oct 11, 2026
58 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22772-inbound-hook-member branch October 11, 2026 08:15
This was referenced Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants