Skip to content

fix(trigger-api,spec): one answer for every inbound hook post that does not verify - #22822

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22806-uniform-hook-refusal
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22806-uniform-hook-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22806

Clause-②: yes

Dispatched by the domain:services seat 1 PM (claim 6107768529, branch claude/issue-22806-uniform-hook-refusal). Direction: triage 6107336164. Disclosure: the card is security, so this body names classes and positions only.

What changes

ApiTrigger.handleRequest (packages/triggers/trigger-api/src/api-trigger.ts) now gives every inbound post that does not verify the same answer: one status and one body. That covers an unknown flow, a wrong hook id, an absent signature and a bad one, whatever hook id the flow is armed with. The answer is 401 INVALID_SIGNATURE, the refusal a bad signature already got.

  • FROM → TO. An unknown flow and a wrong hook id used to answer 404 RESOURCE_NOT_FOUND. They now answer the same 401 INVALID_SIGNATURE as an absent or bad signature.
  • The order is unchanged. The hook is matched before its secret is read. So the secret-unavailable 503, the residual triage kept, still answers only a post that names an armed flow and its hook id. A wrong hook id never reaches the secret read, so it gets the uniform 401 even while the secret is unreadable.
  • The cause is logged. Each refusal logs one warn line with structured fields { cause, flowName }. cause is one of unknown-flow, wrong-hook-id, or signature- followed by core verifyHttpSignature's own reason (absent, malformed, mismatch, outside-window). The line never carries the secret, the signature value or the body.
  • The "no oracle" comment is restated. It now claims what ships, and nothing more: the answer (status and body) is uniform. It names the 503 as the one residual.
  • packages/spec, docblock only, under the cross-domain exception triage named. The ITriggerApiService.handleInboundHook docblock now states the one refusal (FROM → TO) and names the 503 as its residual. Types are unchanged. The spec's contract test (trigger-api-service.test.ts) pins types only and no status text, so it does not move.
  • Changeset. .changeset/22806-uniform-hook-refusal.md, minor for @objectstack/trigger-api and @objectstack/spec. It states FROM → TO, what each kind of sender sees, the log fields and the 503 residual.

Measurement at the door (step 1)

These are answers from the real handleRequest, armed through start() as the engine arms it. They were read on c11b75870 (the base) and on this branch, unsigned and with a bad signature.

post base c11b75870 this branch
a flow that is not armed 404 RESOURCE_NOT_FOUND 401 INVALID_SIGNATURE
an armed flow on the default hook id 401 INVALID_SIGNATURE 401 INVALID_SIGNATURE
an armed flow on a custom hook id, posted with another hook id 404 RESOURCE_NOT_FOUND 401 INVALID_SIGNATURE
an armed flow on a custom hook id, posted with that hook id 401 INVALID_SIGNATURE 401 INVALID_SIGNATURE
an armed flow whose secret cannot be read, its own hook id 503 SERVICE_UNAVAILABLE 503 SERVICE_UNAVAILABLE (the residual, unchanged)
an armed flow whose secret cannot be read, another hook id 404 RESOURCE_NOT_FOUND 401 INVALID_SIGNATURE
CONTROL: a valid post, default and custom hook id 202 202

On the base, the unsigned answer separated armed from unarmed names only for a flow left on the default hook id. A custom hook id already closed it, as long as that hook id stayed unknown. With this change, the answer separates nothing in either case.

Boundary flag: the status (for the contract-tier review)

The status is the reviewer's call, not this PR's. Both candidates are already declared codes. This PR implements 401 INVALID_SIGNATURE and recommends it. If the review rules for 404, that is a patch round.

axis A: uniform 401 INVALID_SIGNATURE (implemented) B: uniform 404 RESOURCE_NOT_FOUND
Real business need Correct sender: 202, no change. A sender with a wrong secret, wrong signing code, re-serialised body or skewed clock still gets 401, so it looks at its signature, which is right. A sender with a wrong URL (a flow that is not armed, a rotated hook id) gets 401 where it got 404; the warn line names unknown-flow / wrong-hook-id. Measured: no SDK client method builds a /automation/hooks/* URL (TRIGGER_API_ROUTE_LEDGER), so no first-party consumer reads the old 404. Correct sender: no change. A sender with a bad signature, including a replay or a clock outside the window, gets 404 No such hook, which points it at the URL when the signature is what is wrong. A sender with a wrong URL gets 404, as before.
Long-term soundness One code means "not verified". The error-code ledger row @objectstack/trigger-api: INVALID_SIGNATURE stays true with no ledger edit. Pending .changeset/22769-versioned-http-signature.md ("the same 401 INVALID_SIGNATURE a bad signature gets") stays true. INVALID_SIGNATURE loses its only emitter. The ledger row goes stale, so the spec needs a code retirement or keeps a dead row, which goes beyond a docblock edit. Pending 22769's refusal sentence becomes false in the same release.
Keeping AI from writing it wrong An agent wiring a sender that reads 401 fixes the signature, the most common integration fault. A wrong URL is the rarer case, and the log names it. An agent that reads 404 rewrites the URL while the secret is wrong: the misleading direction lands on the most common fault.
No scope spread Docblock-only spec edit, no new code, no ledger change. Adds an error-code ledger change to the spec diff.

Recommendation: A. On every axis it serves the common failure correctly and keeps the declared vocabulary true without touching the ledger.

Pins (all on the real handleRequest)

In packages/triggers/trigger-api/src/api-trigger.test.ts, the block "one answer for every post that does not verify" covers 24 posts. Five addresses (two unknown, three armed, including a custom hook id posted with another hook id) are each sent unsigned, malformed, and badly signed in both forms. Four more posts carry a right secret and still must not verify: the wrong hook id, another flow's secret, and a timestamp outside the window.

  • Every answer equals every other. The first is 401 / INVALID_SIGNATURE / success: false. Message prose is not pinned. Nothing is enqueued.
  • The log records each refusal's cause once, asserted by its cause field, not prose. No logged argument contains either secret, the posted signature value, or a body marker.
  • CONTROL: a valid post is accepted under the default and a custom hook id, in both signature forms.
  • CONTROL: the 503 residual is unchanged, signed or not. A wrong hook id on that flow gets the uniform 401 and never reads the secret.
  • Six existing pins that asserted the old 404 now assert 401 / INVALID_SIGNATURE: the unknown-flow-versus-wrong-hookId pin, the three refused-to-arm pins, the stop() pin (now with a valid signature), and the read-time-secret wrong-hook-id pin.

Red before green: on the base source, with the pins in place, 8 failed and 19 passed (27).

Ablations (committed state f26b2f45f, through scripts/ablation-replace.mjs, which wraps the run and restores)

  1. The old distinct answer on the match branch. The two match refusals were replaced with one 404 RESOURCE_NOT_FOUND return. The anchor went from 1 hit to 0, and the blob went from a920e032dcdc to 16afdd2829de. Result: 8 failed, 19 passed (27), the same eight pins as the base. Restored: blob equals HEAD (a920e032dcdc), and git diff HEAD is empty.
  2. The logged cause dropped. The cause field was removed from the refusal line's fields (anchor 1 hit to 0, blob a920e032dcdc to a7bb3a4e349f). Result: 1 failed, 26 passed (27), the log pin. Restored: blob equals HEAD, and git diff HEAD is empty.

The pins import ./api-trigger.js (source). @objectstack/core is aliased to source in this package's vitest.config.ts, so no dist/ is on the path.

Verification

All of the following ran at head 9f82771b0, after the last commit. Builds, tests and gates ran under scripts/pm/os-verify-lock.sh, and each exit code was recorded to disk before any pipe.

  • ① Dependency closure: pnpm --filter '@objectstack/trigger-api^...' build, exit 0. After the docblock commit, spec, trigger-api and the packages the prerequisite gates read were rebuilt (turbo run build, exit 0).
  • ② Affected packages:
    • pnpm --filter @objectstack/trigger-api test: 2 files, 42 passed.
    • pnpm --filter @objectstack/trigger-api typecheck: exit 0. Its tsconfig includes src/**/*, which takes in the test file.
    • pnpm --filter @objectstack/spec test: 648 files, 19342 passed, 1 todo.
    • pnpm --filter @objectstack/spec typecheck: exit 0, at f26b2f45f. The only later change is a comment.
    • pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date".
  • ③/④ Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack gave 86 commands, the same list before and after the last commit. All 86 ran with exit 0. Reconciled with --ran (each line carrying :: exit N): "86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero — all 86 recorded an exit code and none of them is 3)".
  • Also run, outside the derivation, all exit 0: check:error-code-provenance (spec), check:error-code-casing, check-changeset-fixed.mjs and check:route-ledger-census.
  • Left to CI: the path-scheduled CI jobs and type-check lanes that dispatch-gates lists as outside its derivation, and the repo-wide pnpm lint.

Acceptance notes (noted, not filed)

  • What the claim covers. The answer is uniform in status and body. The code makes no claim about response time across the hook lookup, and that was not measured. Carrier: none.
  • Pending changeset text. .changeset/22772-spec-trigger-api-inbound-hook-member.md (unreleased) still says an unknown flow or wrong hook id answers 404. If both ship in one release, this PR's changeset states the FROM → TO beside it. That changeset belongs to another card and is not edited here. Carrier: whoever compiles that release's notes.
  • Signature-form text in the docblock. The verification bullet in handleInboundHook's docblock still names only the body-only form, while the runtime has also accepted the timestamped form since 7b0a9c93c. The accepted forms are outside this dispatch, so the merged wording is kept as is. Carrier: none.
  • Log volume. One warn line per refused post means anonymous traffic can add lines at request rate. That is the price of keeping the cause visible to the operator. No deduplication is added.
  • No WWW-Authenticate header. The route has never sent one with its 401, and this change keeps that.

Generated by Claude Code

…es not verify

An unknown flow, a wrong hook id, an absent signature and a bad one now all
get the same 401 INVALID_SIGNATURE status and body from handleRequest, where
the first two answered 404 RESOURCE_NOT_FOUND. The cause goes to the server
log as a structured field only. The secret-unavailable 503 is unchanged and
named as the residual; the hook is still matched before its secret is read,
so that 503 answers only a post that names an armed flow and its hook id.

The ITriggerApiService.handleInboundHook docblock states the one refusal and
the residual (FROM -> TO), and defers the accepted signature forms to core's
verifyHttpSignature.

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
The accepted signature forms are outside this change. The verification
bullet keeps its merged wording and only hands a missing or wrong
signature to the one unverified-post refusal.

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/spec, @objectstack/trigger-api, touching 9 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/api/error-catalog.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/api/error-handling-client.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/automation/webhooks.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/kernel/contracts/auth-service.mdx (via handleRequest (symbol, a method of class ApiTrigger))
  • content/docs/kernel/contracts/metadata-service.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/permissions/authentication.mdx (via handleRequest (symbol, a method of class ApiTrigger))
  • content/docs/permissions/sso.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/protocol/kernel/error-handling.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))

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

  • content/docs/releases/v17/17-0.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))
  • content/docs/releases/v17/17-5.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in handleRequest))

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
  • 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 b7cd1af9df7ade29e165e838503cbbef05e4299e → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9f82771b0fb6b4ba81632017a1600d5c09f2ab5f
Local-runs: none

Inputs, and nothing else: card #22806 (body, comments 6107336164, 6107768529, 6108319846; 6108335772 not read, per the brief); PR #22822 (body, file list, net diff against main); the check-runs on the head (36 runs, latest per name); on origin/main at b7cd1af9d: packages/spec/src/contracts/trigger-api-service.ts, packages/spec/src/api/error-code-ledger.zod.ts (the @objectstack/trigger-api row block, lines 1006 to 1019), packages/spec/src/api/errors.zod.ts (the standard catalog and its status map), .changeset/22769-versioned-http-signature.md, .changeset/22772-spec-trigger-api-inbound-hook-member.md, packages/triggers/trigger-api/src/api-trigger.ts and plugin.ts, packages/core/src/security/http-signature.ts and the core barrel, packages/spec/scripts/check-error-code-provenance.ts (header), skills/objectstack-automation/SKILL.md (the inbound-webhook section), docs/qa/platform-checklist/areas/api-backend.json (the raw-app mount-parity item). Read-only: no worktree, no build, no test, no gate re-run. Disclosure: the card is security, so this record names classes and positions only.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged.

  1. Accept-set of the door: unchanged. Right. What verifies and is enqueued (202) is the same set as on main: an armed flow, its hook id, and a signature that verifies under that hook's secret in either accepted form. The CONTROL pins hold it under the default and a custom hook id, in both forms. Nothing an author can write moved.
  2. Refusal surface of the door: 404 RESOURCE_NOT_FOUND for an unknown flow and for a wrong hook id becomes 401 INVALID_SIGNATURE with the body a bad signature already got. Right. This is the one answer triage 6107336164 directed (one status, one envelope for an unknown flow, a wrong hook id, an absent signature and a bad one). unverifiedPostAnswer() returns a fresh object per call, and every refusal path reaches it through refuseUnverified. No new wire string, no new code: INVALID_SIGNATURE is already the trigger-api's registered row (ledger line 1018) and trigger-api is its only emitter in packages/**; RESOURCE_NOT_FOUND is a standard-catalog member (errors.zod.ts:88), not a ledger row, so dropping trigger-api's last emission of it needs no ledger edit and the provenance gate (which reconciles emitters to rows, not rows to emitters) is unaffected.
  3. Order of the three checks: unchanged (match, then secret read, then verify). Right. The pin that the secret is read zero times for a wrong hook id is kept and re-stated. This is what keeps the residual below narrow.
  4. The 503 residual: unchanged, and now declared. Right. An armed flow whose secret cannot be read answers 503 SERVICE_UNAVAILABLE to a post naming that flow and its hook id, signed or not; a wrong hook id on that flow gets the uniform 401 without reading the secret. That is exactly what triage asked to keep and to name; the contract docblock and the changeset both name it as the one residual. The residual still lets a post that names an armed flow with an unreadable secret see a status no unarmed flow gives; triage accepted that as the price of retry semantics, and the record notes it as the residual's class, nothing more.
  5. Server-side cause, warn, structured { cause, flowName }. Right. UnverifiedPostCause derives its signature-* arm from core's HttpSignatureVerdict reason union (absent, malformed, mismatch, outside-window; http-signature.ts:120-122, exported through the core barrel), and UNVERIFIED_POST_CAUSE_TEXT is a Record over the whole union, so a new reason in core fails this package's typecheck instead of logging an untitled cause. The outside-window text is accurate to core, which reaches that reason only after the HMAC verified. TriggerLogger.warn(msg, ...args) is variadic and mirrors ctx.logger, which is what plugin.ts passes, so the meta object reaches the composed logger; the cause also rides the message text, so the operator claim holds on a logger that prints only the first argument. The pins assert the cause by field, once per post, and that no logged argument carries either secret, the posted signature value, or a body marker. The level is right: a refusal handed to the caller is not a durability degradation (AGENTS.md, degradation log levels), and warn matches the route's existing lines. Position only: the flowName field on an unknown-flow refusal is the sender's own string, carried as a structured field, never interpolated into the message.
  6. The "no oracle" comment restated to status and body only. Right. The claim is now exactly what ships; response time across the hook lookup is explicitly not claimed (the PR says so). Triage's wording was "one status and one envelope", so the scope of the claim matches the card's "Done when". Whichever of this PR and trigger-api: implement the declared inbound-hook member through the same verifier, read http.server before the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774 lands second leaves the comment true (pointer 6106820278).
  7. packages/spec: the handleInboundHook docblock, types unchanged. Right, and in scope. It rides under the cross-domain exception triage named for this lane. The FROM to TO is stated in the docblock itself, and the 503 is named as the residual. The spec's contract test pins types only (one describe, one it on optionality), so it does not move. Published .d.ts carries the docblock, so this is a published-surface text change, which is why the spec changeset exists (see ②). One pre-existing line in that docblock is left as it was on main (the verification bullet still names only the body-only form while core has accepted the timestamped form since 7b0a9c93c); the dev kept it deliberately because the accepted forms are outside this dispatch. Judged right to leave; escalated in ③.
  8. Six existing pins rewritten from 404 to 401 / INVALID_SIGNATURE. Right. Each is the old answer's assertion under the new contract. The stop() pin now posts with a valid signature and still expects the refusal with nothing enqueued, which is the stronger statement (a disarmed hook refuses even the right secret).
  9. The new pin block: 24 refused posts (5 addresses times 4 signatures, plus 4 right-secret-still-unverified posts), every answer equal to the first, nothing enqueued; the log pin; two CONTROLs. Right. The case arithmetic is asserted in the test itself. The right-secret cases cover the two subtle classes the card asks for: a right secret on the wrong hook id, and a right secret with a timestamp outside the window. The CONTROL for the 503 residual pins the secret read count across the signed and unsigned post and that a wrong hook id does not add a read.
  10. Other surfaces that name this door, read on main: none moves, none goes stale. Right. skills/objectstack-automation/SKILL.md (Tier H) documents the door without stating the 404 / 401 split, so no governed surface is owed. docs/qa/platform-checklist/areas/api-backend.json (the raw-app mount-parity item) requires only "a structured JSON refusal, distinguishable from the routing 404" for an unknown flow; the new 401 satisfies it more plainly than before. The route ledger declares disposition only, no status. content/docs/references/api/error-code-ledger.mdx and contract.mdx are generated from the ledger, which is unchanged. No implementer of handleInboundHook exists on main yet (the hosted segment runtime + core: an exact POST /automation/hooks/:flowName/:hookId dispatcher domain and its parameterised ADR-0069 allow-list row, anonymous through to the trigger's HMAC verifier only (trigger-api segment 2 of ruling A on #22757) #22773 has not landed), and when it does it serves handleRequest's answer, so it inherits the uniform refusal from the one verifier.
  11. The plugin's self-hosted mount serves handleRequest's { status, body } verbatim (plugin.ts:92). Right: nothing in the mount re-maps a status, so the diff's answer is the wire answer.

Nothing in the diff touches core's http-signature, the accepted signature forms, arming, the default hook id, the mount's routing, or the hosted dispatcher. Scope held.

② Semver level

  • Changeset .changeset/22806-uniform-hook-refusal.md: @objectstack/trigger-api: minor, @objectstack/spec: minor. Both are released packages (17.7.0, neither private), and the diff moves published source in both: runtime behaviour on a published door in trigger-api, and the declared status contract (a .d.ts docblock that IS the contract of record since PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779) in spec. Not skip-changeset: right.
  • Clause-②: yes, bare, in both the PR body and the changeset body (where the gate reads it). Right. Triage and the dispatch both declared yes (a contract change on a published door), and yes lifts the floor to minor (AGENTS.md, Post-Task Checklist 3), which is why this fix(...) is not a patch. No arm is right: the accept-set (what the door accepts and enqueues) is unchanged, so neither (widening) nor (narrowing) applies; nothing an author can write, no export, no config field is removed or renamed, so no ADR-0087 disposition is owed and no breaking marker is missing. What changes is the refusal vocabulary at the door, and the changeset carries that as FROM to TO plus a "To adopt it" paragraph for a sender or monitor that keyed on the old 404, which is the migration text the checklist asks a changeset to ship.
  • The Check Changeset run on the head is success.
  • This record's own reading of the declaration, spelled the one legal way, on the next line.

Clause-②: yes

A published door's declared refusal contract changes; the accept-set does not; minor on both packages is the right level, and the PR's and changeset's bare yes are both right.

③ Boundary flags

The status question (the dev's one open_questions entry, 6108319846; triage 6107336164 put the choice in this review). Ruling: A, 401 INVALID_SIGNATURE, the status the diff implements. Reasons, in order of weight:

  1. It is the true statement. The contract itself declares that the signature is the only credential at this door, and the hook id is part of that credential (it is the rotate-to-revoke token). A post that does not verify is, from the door's side, a post that presented no valid credential for any armed hook, which is what 401 says. Under B the door would tell a sender with the right URL and a wrong secret that the hook does not exist: a machine-readable surface asserting a falsehood about a resource that exists, against the repo's own rule that such surfaces must not lie (AGENTS.md, Route and surface ownership, rule 4).
  2. Vocabulary stays closed and live with no ledger edit. INVALID_SIGNATURE is trigger-api's registered row and trigger-api is its sole emitter. Under B the row would promise a code nothing emits, exactly the "stale rows promising decisions nobody is standing on" the ledger's own header refuses, so B needs a code retirement or a waiver on top of the docblock, which breaches the dispatch's spec scope (docblock only) and widens a security card into a vocabulary change. RESOURCE_NOT_FOUND is the standard catalog's 404 member, and B would make the door its emitter for a condition that is not "not found".
  3. The hosted shape keeps two distinguishable answers. The contract says an empty trigger-api slot or an absent member answers "a typed 404 or 501, never ROUTE_NOT_FOUND". Under B the door's uniform refusal and the dispatcher's "service missing" answer would share a status, and a sender could no longer tell "nothing is mounted here" from "you did not verify" without parsing bodies. Under A they stay apart.
  4. Pending changesets stay true in the same release. .changeset/22769-versioned-http-signature.md ("the same 401 INVALID_SIGNATURE a bad signature gets") remains true under A and becomes false under B. (22772 is addressed below.)
  5. The common sender fault gets the right pointer. A wrong secret, wrong signing code, re-serialised body or skewed clock is the usual integration failure; 401 sends that sender to its signature. Under B the same sender is sent to its URL. The rarer wrong-URL sender now gets 401 instead of 404, and the operator's log names unknown-flow or wrong-hook-id for it.

Because the diff implements A, no patch round follows from this ruling.

Every other dev flag, answered:

  • Lock conflict (gates under os-verify-lock in four chunks). Mechanics only; the more restrictive reading harms nothing, and the check-runs on the head are the gate verdicts this record relies on. Accepted.
  • Commit f26b2f45f's message names a docblock sentence the head no longer carries. The PR body, the changeset and the head's diff are the record that ships, and they agree; a squash composes both messages. No action.
  • Model-free commit trailers. Correct per AGENTS.md. No action.
  • No labels written. Correct; needs:contract-review on the PR was set by another actor and is not the dev's to touch. No action.
  • Branch behind origin/main (now b7cd1af9d), no merge done. The PR is mergeable and CI runs the merge ref; the queue rebuilds on landing. No action for this review.
  • Response time across the hook lookup is not claimed. In scope was one status and one envelope (triage's words); the code's comment now claims exactly that. Accepted, class only.
  • .changeset/22772-spec-trigger-api-inbound-hook-member.md (pending, unreleased) still states the old 404 split. Not a defect on this head: that changeset belongs to another card and the dispatch scoped this PR to .changeset/22806-*.md; when both are consumed by one version pass, this PR's entry states FROM to TO beside it in the same section, so the published CHANGELOG is self-correcting for a reader. Escalated to the seat, not blocking: a one-line docs-only amendment of the pending 22772 changeset (its "What it serves" bullet) before the next version pass would remove the contradiction at the source; the carrier is the domain:spec seat or the release-notes compiler.
  • The handleInboundHook docblock's verification bullet still names only the body-only signature form. Pre-existing drift from 7b0a9c93c, outside this dispatch (the accepted forms were excluded), correctly left untouched. Escalated to the seat, not blocking: a docs-only follow-up on the 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 / spec: declare the forward target for trigger-api's inbound hook (POST /automation/hooks/:flowName/:hookId) — an optional, transport-neutral member a dispatcher domain can reach (trigger-api segment 1 of ruling A on #22757) #22772 lane, carrier domain:spec. Dedupe words as the dev gave them: handleInboundHook docblock signature form timestamped v1.
  • Log volume: one warn line per refused post. Accepted: the cause has to live somewhere the sender cannot read, the level is right, and the route's existing 503 path already logs per post. Position only.
  • No WWW-Authenticate on the 401. Pre-existing and unchanged; the credential is a custom HMAC header, not an HTTP auth scheme. Not this card's; noted, not filed.

CI convergence on the head. Read from the check-runs on 9f82771b0fb6b4ba81632017a1600d5c09f2ab5f after the last one finished (latest run per check name, 35 names, 39 runs): 35 completed, 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in progress. All seven required contexts are success: Lint and Repo Gates, TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate (all three shards), Build Core, Temporal Conformance (live PG and MySQL), Governed Surface Queue Guard. The PR head did not move during the wait (updated_at 2026-10-11T11:02:48Z throughout). Those conclusions are the gate verdicts this record adopts; nothing was run locally.

Implemented-by: claude/issue-22806-uniform-hook-refusal
Reviewed-by: session_01CBAfsWMSfM3EToQGVStEcp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 11:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 11:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit efcbac7 Oct 11, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22806-uniform-hook-refusal branch October 11, 2026 12:04
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