Skip to content

feat(trigger-api)!: the inbound hook door refuses the body-only signature form, so only the timestamped form verifies (ruling B on #22805, step 3) - #22887

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22826-refuse-body-only-signature
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22826-refuse-body-only-signature

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22826
Clause-②: no (narrowing)

Step 3 of ruling B on #22805 (record 6108492902, maintainer 「同意」), at the position triage set (6108760218): the trigger-api inbound hook door refuses a verdict whose scheme is body-only. Core's verifyHttpSignature keeps both forms, because it is the receiver half of the body-only form our own outbound senders sign.

What changes

  • The door (packages/triggers/trigger-api/src/api-trigger.ts). One private function, verifyInboundSignature, makes the door's signature judgement: core's verdict, then an exhaustive switch over the verified scheme. v1 is accepted. body-only is refused as signature-body-only. handleRequest and the exported verifySignature both call it, so the export answers what the door answers. The refusal runs right after verification, before the JSON parse and the enqueue, through the existing refuseUnverified. So a body-only post gets the uniform 401 INVALID_SIGNATURE built by PR fix(trigger-api,spec): one answer for every inbound hook post that does not verify #22822, and nothing is enqueued, even when the value is the secret's HMAC of the body.
  • The deprecation line is gone, with bodyOnlyDeprecationLogged and its initialiser.
  • The cause vocabulary gains exactly one entry, signature-body-only, in the same voice as signature-outside-window. The signature-malformed text no longer says "neither accepted form", because one form is accepted now.
  • Describe-only text:
  • Changeset .changeset/22826-refuse-body-only-signature.md. @objectstack/trigger-api is major: a breaking narrowing on the v18 line in next pre mode, the grade 22502/22565/22572 used. @objectstack/spec is patch: the docblock reaches dist/contracts/index.d.ts. The changeset has the BREAKING banner and FROM → TO. Its ADR-0087 disposition is not-required (no-migration-prescription): no metadata moves, and what narrows is a value a third-party sender computes.

⛔ Not touched: packages/core (verifyHttpSignature, signHttpBody), the outbound senders, webhooks.mdx §6, ADR-0138 (its D7 sentence waits for that ADR's next revision, per the ruling), content/docs/releases/.

Pins beside the door (done-when, one by one)

done-when pin
body-only, valid for the secret, is refused with the uniform 401, nothing enqueued api-trigger.test.ts "refuses a body-only signature that IS the secret's HMAC of the body". It first asserts core verifies the value as { valid: true, scheme: 'body-only' }, so the refusal is the door's policy and not a broken fixture. It then asserts the full envelope, an empty queue, no flow run, and exactly one warn per post, which is the refusal with cause signature-body-only.
the same, in the uniform table (the cause log line) two new SIGNED_BUT_UNVERIFIED rows, under the default hook id and a custom one. They feed "every unverified post gets the same status and the same envelope" and "the server log records the real cause of each refusal".
the same, hosted shape and parity inbound-hook-member.test.ts: the arm-table row 202 becomes 401 INVALID_SIGNATURE, published false. The member/mount parity table compares the same row.
CONTROL: timestamped inside the window accepted "CONTROL: accepts a fresh timestamped post", plus the window-edge test.
CONTROL: outside the window refused "CONTROL: refuses a replay outside the window", plus "CONTROL: refuses a stale timestamp the HMAC genuinely covers".
CONTROL: outbound signHttpBody output unchanged new "CONTROL: the outbound form is unchanged". Core's signHttpBody still writes the body-only value byte-for-byte as the published recipe, and core still verifies it.
the deprecation warning is gone the "exactly one warn per post" assertion above (ablation C below).
verifySignature agrees with the door both verifySignature tests assert false for the right secret's body-only value.

Ablation: every negative pin, red, then restored

Each mutation went through scripts/ablation-replace.mjs (wrap mode, anchor hit 1 → 0, blob changed) inside a wrapper that has its own trap … EXIT INT TERM restore. Each was restored with git checkout HEAD -- ABSOLUTE_PATH and proven by blob equality with HEAD:path (aff2541dd93b) and an empty git diff HEAD. The trigger-api suites import ./api-trigger.js / ./plugin.js relatively, straight from source, so no build or dist/ leg applies. The dogfood suite aliases @objectstack/trigger-api to source.

  • A: body-only accepted again (return { valid: true }; for body-only). 7 failed / 48 passed (55): both verifySignature tests ("expected true to be false"), the refusal test ("first post: expected { status: 202 … } to deeply equal { status: 401 … }"), both uniform-table tests ("the right secret, in the body-only form: expected { status: 202 … }" and "expected [] to deeply equal [ 'signature-body-only' ]"), the member arm table ("expected 202 to be 401"), and parity ("the mount's own answer: expected 202 to be 401"). Restored: OK.
  • B: cause mislabelled (signature-mismatch). 2 failed / 53 passed: "expected [ 'signature-mismatch' ] to deeply equal [ 'signature-body-only' ]" in the refusal test and the cause-log test. The envelope pins stay green, as they should. Restored: OK.
  • C: a deprecation warn put back beside the refusal. 1 failed / 54 passed: "expected "vi.fn()" to be called 1 times, but got 2 times". Restored: OK.
  • Reverse check on the real composition. The dogfood credential-channel suite was run with its old body-only signer against the new door. 7 failed / 3 passed (10), each "expected 401 to be 202", through the real engine, the credential channel and the real ApiTrigger. Restored: OK. This is why the suite's signer flips (below).

Whole-repo pin sweep

Grepped for the signature header, sha256=, signHttpBody, verifyHttpSignature, INVALID_SIGNATURE, the hook path and ApiTrigger across the repo.

  • Flipped:
    • api-trigger.test.ts: the two "body-only still accepted" tests, the "both forms" control and the two verifySignature tests. Every valid post in the older describes now signs the timestamped form, and the duplicate local sigV1 helpers became one file-level pair.
    • inbound-hook-member.test.ts: the body-only 202 row.
    • packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts: its signer was body-only against the real door, so every 202 it expects would be 401; it now signs the timestamped form.
    • packages/runtime/src/domains/automation-hooks.test.ts: the member double that "carries the trigger's verifier" now accepts v1 only, as the door does, and its posts sign v1. It is outside the claim's declared surface; it is here because ruling B's sweep covers the whole repo, and the change is the double's fidelity, not a new pin.
  • Left verbatim, because they guard a still-illegal shape: the sha256= value carried with a t= prefix (refused, no fallback), body-only under another secret (signature-mismatch), sha256=zz, and the 503 rows signed with a body-only value under a wrong secret.
  • Hand-written docs: no sentence in content/docs/** (outside releases/ and references/), the showcase, the QA checklist or the skill tells a sender the inbound door accepts the body-only form. inbound webhook guidance: every place that teaches a sender teaches the timestamped t=…,v1=… signature only (ruling B on #22805, step 1) #22825 had already rewritten them. Nothing to correct.

Measured against the dispatch's mechanism assumptions

  1. Holds. verifyHttpSignature returns { valid: true, scheme: 'v1' | 'body-only' }, and handleRequest read it at the deprecation branch. No core change was needed.
  2. Holds in kind, but the count is off by two. Before this PR the vocabulary had six causes (unknown-flow, wrong-hook-id and four signature-*). Adding exactly one makes seven, five of them signature-*. "The four-cause log line" is read as "the cause log line pins the new cause", and it does.
  3. Holds. The arm-table and parity rows flipped in place.
  4. Holds: there was nothing to correct (above).

Verification at 78b92702d

  • pnpm --filter @objectstack/trigger-api exec vitest run --maxWorkers=2: 3 files, 55 passed.
  • pnpm --filter @objectstack/trigger-api typecheck: exit 0 (the tsconfig includes src/**/*, tests included).
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-hooks.test.ts: 19 passed. pnpm --filter @objectstack/runtime typecheck: exit 0, test layer held in its ledger.
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/flow-credential-channel.dogfood.test.ts: 10 passed. pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • pnpm --filter @objectstack/spec exec vitest run src/contracts/trigger-api-service.test.ts: 1 passed. pnpm --filter @objectstack/spec typecheck: exit 0.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived on this change: 87 commands. That is a superset of the dispatch's 78; the nine extra ones are the changeset gates, check:route-envelope and two release self-tests. All 87 ran, plus pnpm --filter @objectstack/spec check:generated ("All 14 generated artifacts are up to date"), and every one exited 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: eight unrelated packages had no dist/). Those packages were restored from turbo's cache, and the re-run exited 0: "107 published require entry point(s) across 66 package(s) load". dispatch-gates --ran, given every command with its exit code: "87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all 87 recorded an exit code and none of them is 3)".

Declared narrowing: for @objectstack/spec the change is TSDoc text in one interface, so its full suite is declared to CI and only the contract's own test, the typecheck and the derived spec gates ran here. For @objectstack/runtime and @objectstack/dogfood only the edited test files ran, with each package's typecheck. Their full suites (the dogfood gate included) are CI's.

Acceptance notes

  • ADR-0041 line 119 still says "HMAC signature verification (GitHub/Stripe style)". GitHub's style is the body-only form. This is a governed surface, so it is noted and not edited; its natural carrier is ADR-0041's next revision, beside ADR-0138 D7's correction.
  • packages/core/src/security/http-signature.ts's docblock ("so the body-only form stays accepted; refusing it is a decision of its own, not this definition's") stays true: the decision is now taken, at the door.
  • The contract-tier review of record (CONTRACT_REVIEW_TIER) for the describe-only spec text and the breaking narrowing is the owning seat's, after this PR.

Generated by Claude Code

…ure form

A post whose x-objectstack-signature is in the body-only form now gets the
same 401 INVALID_SIGNATURE every unverified post gets, logged with the cause
signature-body-only; the deprecation line goes with the branch. The judgement
lives in one function both handleRequest and the exported verifySignature
call, so the export agrees with the door. Core's verifyHttpSignature keeps
both forms: it is the receiver half of the outbound body-only form.

The ITriggerApiService.handleInboundHook docblock names the timestamped form
as the only one accepted.

Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA
Co-authored-by: Claude <noreply@anthropic.com>
…ws that accepted it

The body-only signature, right for the secret, now answers the uniform 401
with its own logged cause and enqueues nothing, through the trigger, the
uniform-refusal table and the member's hosted-shape arm table (the parity
table follows it). Valid posts in the older describes sign the timestamped
form. Controls beside them: the timestamped form inside the window is
accepted, outside it is refused, and core's outbound signHttpBody output is
unchanged.

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

The dogfood credential-channel suite drives the real ApiTrigger and signed
every post body-only, so each 202 it expects would now be a 401. The runtime
dispatcher suite's member double carries the trigger's verifier; it now
accepts the timestamped form only, as the door does, and its posts sign that
form.

Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA
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). ⚠️ 1 changed file(s) yielded no anchor (packages/triggers/trigger-api/src/plugin.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/kernel/contracts/auth-service.mdx (via handleRequest (symbol, a method of class ApiTrigger))
  • content/docs/permissions/authentication.mdx (via handleRequest (symbol, a method of class ApiTrigger))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/triggers/trigger-api/src/plugin.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388

⚠️ 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 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 → 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: 78b92702d35fe6ca924a4c962d8c39190d009a11
Local-runs: none

Rendered by an isolated reviewer subagent of the seat in Reviewed-by, which adopts it.

Inputs, read 2026-10-11T21:19Z: card #22826 (body and all five comments: triage 6108760218, unlock 6110352531, claim 6112250834, report 6112859544, the seat's ACCEPT 6112924906, the last read as data and re-judged); ruling B 6108492902 on #22805; PR #22887 body and its 9-file list; the net diff from the merge-base 75b6d6a04 to the head, which is byte-identical to the three-dot diff against main at b1d701b60 (main moved on none of the 9 files); the head's check-runs. ⛔ Classes and positions only (security): no reproduction recipe here.

Check-runs on the head, read 2026-10-11T21:19Z: 39 runs, all completed: 33 success, 6 skipped, 0 failed. Latest per name: 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke: path or opt-in skips). All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its 6 shards), Dogfood Regression Gate (and its 3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset is success; that job runs check-empty-changeset, check-adr-0087-registration and check-changeset-no-major. An earlier read in this round still had Test Core and the Dogfood gate in progress; the read above governs.

① Derived judgments

  1. Inbound door accept set narrows (POST /api/v1/automation/hooks/:flowName/:hookId, through the self-hosted mount and through ITriggerApiService.handleInboundHook alike): a post whose x-objectstack-signature is the body-only form goes from 202 + enqueue to the uniform 401 INVALID_SIGNATURE with nothing enqueued, even under the hook's own secret. Right. This is ruling B step 3 at the position triage set (the door, not core's export). The refusal is one private verifyInboundSignature (core's verdict, then an exhaustive switch on scheme), called after the secret read (the 503 residual is unchanged) and before the JSON parse and the enqueue, through the existing refuseUnverified. Pins beside the door: the right-secret body-only test (asserts core still answers valid: true, scheme: body-only first, so the refusal is policy, then the envelope, an empty queue, no flow run, exactly one warn); two new rows in the uniform table; the member arm-table row flipped to 401 and the member/mount parity table; CONTROLs for a fresh timestamped post, a replay outside the window, a stale t the HMAC covers, and outbound signHttpBody byte-identical.
  2. @objectstack/trigger-api public export verifySignature(secret, rawBody, header): signature unchanged, answer narrows (false for a body-only value). Right. The door and the export share one judgement, so no second verifier appears; the narrowing is declared in the changeset; measured on main, no in-repo consumer exists beyond the index.ts re-export.
  3. @objectstack/core: zero lines. verifyHttpSignature keeps both forms; signHttpBody is unchanged (CONTROL pin). Right per triage: it is the receiver half of the form our outbound senders sign, and its docblock sentence that refusing body-only "is a decision of its own, not this definition's" (verified on main) now has its decision, taken at the door.
  4. Cause vocabulary: module-private UnverifiedPostCause gains signature-body-only, seven in all (unknown-flow, wrong-hook-id, the four from core's reason union, the new one); UNVERIFIED_POST_CAUSE_TEXT is a Record over that union with all seven keys, and the switch over core's scheme union (v1, body-only, read on main) has no default, so a scheme core adds later fails to compile at the door. Right. Log hygiene: refuseUnverified emits cause and flowName only, never the secret, the value or the body; the new text carries no tracker number. Right for a security card.
  5. Deprecation path removed: bodyOnlyDeprecationLogged off the internal ArmedHook, the warn line gone, pinned by "exactly one warn per post". Right, the ruling says the line goes with the branch.
  6. signature-malformed text: "neither the timestamped form nor the body-only one" replaces "neither accepted form". Right: core's malformed still means neither shape, and only one is accepted now.
  7. Spec ITriggerApiService.handleInboundHook docblock (packages/spec/src/contracts/trigger-api-service.ts): describe-only, type unchanged; names the timestamped form as the only one accepted and the body-only form as refused; "which of those four" becomes "which of those". Right: the relay 6108557878 carrier, under the exception path; TypeScript Type Check (the spec generated-artifact gates) is green, so nothing is stale.
  8. plugin.ts docblock and trigger-api-route-ledger.ts note: describe-only, made true (both said GitHub/Stripe-style body-only HMAC). Right; the ledger note is a string value in a published export and is covered by the trigger-api changeset.
  9. Test flips outside trigger-api: the dogfood flow-credential-channel signer now signs the timestamped form (the real door would answer 401, and the required Dogfood gate would red); the runtime automation-hooks.test.ts double accepts v1 only and signs with signHttpBodyAt (exported from core's security/index.ts). Right: tests only, same defect class, no production change, no pin removed. Rows guarding still-illegal shapes stay verbatim (body-only under another secret stays signature-mismatch, since core judges the HMAC before the door sees a scheme; a t= prefix carrying a body-only hex; sha256=zz; the 503 rows). Right.
  10. Hand-written guidance, measured on main b1d701b60: sha256= appears only in webhooks.mdx (the outbound §6 contract and §14 industry alignment, both kept by the ruling) and in unrelated #sha256= artifact pins; the skill's inbound section (SKILL.md:351) teaches t=…,v1=… only; lint text, examples and the QA checklist carry none. Nothing owed. The Docs Drift comment's two pages anchor on IAuthService.handleRequest, a same-name symbol, not this door. Nothing owed.
  11. Not touched: core, the outbound senders, webhooks.mdx, docs/adr/**, content/docs/releases/**. 0 of 9 paths governed (register: docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md); head repo equals base repo; 369 changed lines, under 3,000. Right.

② Semver level

  • Changeset .changeset/22826-refuse-body-only-signature.md: @objectstack/trigger-api: major, @objectstack/spec: patch, BREAKING banner, Clause-②: no (narrowing), adr-0087 marker not-required (no-migration-prescription).
  • What the diff publishes: trigger-api, a runtime accept-set narrowing on a published door and on the exported verifySignature's answer (signature unchanged), BREAKING; spec, TSDoc only, reaching dist/contracts/index.d.ts with no type change, so patch is the right floor. No other published package moves (runtime and dogfood changes are test files). skip-changeset would be wrong. Matches.
  • Clause-②: line: no (narrowing) on PR body line 2 and in the changeset, the same text. Right. The clause is a widening (a new key on a published payload); this diff adds none and narrows one accept set, and (narrowing) is the BREAKING arm. Triage's Clause-②: yes in 6108760218 was the wrong arm; the claim and the PR corrected it. The level axis stands down on no.
  • Level: major is accepted. .changeset/pre.json is mode: pre, tag next, with the packages at 17.7.0, so check-changeset-no-major's RC exemption stands aside and the bump lands as 18.0.0-next.N; Check Changeset is green. It matches the three direct precedents pending on main (22502, 22565, 22572: major, no (narrowing)), which already lift the fixed group to the next major pre-release, so this adds no new lift. Lane reading, for the record: pending no (narrowing) changesets on main grade 8 major against 58 minor; both are admitted in pre mode, and during the window the level is not the carrier, the BREAKING banner and the ADR-0087 disposition are, and both are present. Judged right; not re-graded.
  • ADR-0087 disposition not-required (no-migration-prescription): right. Nothing objectstack migrate meta could rewrite moves (no spec key, metadata shape, export signature or stored row); the narrowed value is computed by a third-party sender outside any project's metadata or code; the body states FROM → TO (202 to 401; to the timestamped form, signHttpBodyAt) by pointing at the published guidance, and the gate's statement-against-statement check passed. Not registered or already-registered (no id), not unpublished (both publish), not runtime-interface-only or type-surface-only (no declaration moves).

③ Boundary flags

  • Dev deviation 1 (the count was six causes, not four; now seven): verified from core's reason union plus unknown-flow and wrong-hook-id; the diff's Record holds seven keys. Triage's "four-cause log line" was a miscount; the cause-log pin covers the new cause. Answered, no action.
  • Dev deviation 2 (dogfood signer and runtime double outside the declared surface): same defect class, mechanical, test-only, named in the PR, claim amended by the seat in 6112924906. Answered: right; without the dogfood flip the required Dogfood gate reds.
  • Dev deviation 3 (plugin docblock, ledger note, verifySignature narrows): answered under ① items 2 and 8.
  • Dev deviation 4 (grade major / patch): answered under ②.
  • Dev deviation 5 (local test runs narrowed to the edited files): answered by the head's check-runs, Test Core 6/6 and Dogfood Regression Gate 3/3 green. No action.
  • Dev deviation 6 (main moved two commits after the base, no merge made): verified, main at b1d701b60 has no diff on any of the 9 files since the merge-base 75b6d6a04; the queue rebuilds on current main. No action.
  • open_questions: none filed; none found.
  • Out-of-scope finding (ADR-0041 near :119, "GitHub/Stripe style"): governed Tier H; its carrier is that ADR's next revision beside ADR-0138 D7, which ruling B already assigns. Not a reproducible defect, a contract violation or an authoring trap, so an acceptance note is its right place. Not escalated; nothing for this PR.
  • The seat's ACCEPT 6112924906, read as data: its "two readings" on the level are settled above; its claim amendment agrees with the facts measured here.
  • Disclosure: the PR, the changeset and the docblocks state the accepted and the refused forms, which are the published contract (the skill's inbound section, webhooks.mdx §6); no reproduction recipe appears on any public surface, and the refusal's log line carries no secret, value or body.
  • Landing, for the seat: the PR is draft with needs:contract-review; not governed; under 3,000 lines; every required context green at the read above.

Implemented-by: claude/issue-22826-refuse-body-only-signature
Reviewed-by: session_013LbZ9MhPp1iriZEtgJqioA

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 21:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 21:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 5ce6773 Oct 11, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22826-refuse-body-only-signature branch October 11, 2026 21:52
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