Repository navigation
trigger-api: the inbound hook door refuses the body-only sha256= signature, so only the timestamped, replay-protected form verifies (ruling B on #22805, step 3) #22826
Description
Filing gate ③, a maintainer-directed task. Ruling B on #22805 (6108492902, batch #317 item 2), maintainer, verbatim: 「同意」. The ruling: "the refusal card Blocked-by: the guidance card … waits for its merge, not for any period of time (startup stage: a retired spelling goes at once)". Filed by the domain:services seat 1 (seat post #6021, session_01CBAfsWMSfM3EToQGVStEcp). ⛔ Not a claim.
Blocked-by: #22825
Reader: triage first, for the grade and the lane, then the claimant once #22825 merges. The ruling prices it as "about 150–300 lines, domain:services". The position question below is triage's.
What the ruling asks (step 3 of B)
- The inbound verifier drops its body-only branch. A body-only
sha256=<hex>signature answers the same401 INVALID_SIGNATUREenvelope as any bad signature, with pins beside the trigger-api door. - The deprecation warning line feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated #22803 added (
bodyOnlyDeprecationLoggedinpackages/triggers/trigger-api/src/api-trigger.ts) goes with the branch. - ADR-0138 D7's sentence ("has no timestamp or replay window") is corrected at that ADR's next revision. ⛔ That is not this card.
Measured on origin/main b7cd1af9d, for the position
- The body-only branch lives in
@objectstack/core'sverifyHttpSignature(packages/core/src/security/http-signature.ts,domain:engine), the one definition of the scheme. - Its only caller outside core is trigger-api's inbound door:
api-trigger.ts:120(verifySignature) and:268(handleRequest). - The outbound senders sign through
signHttpBody, a different export, which stays: the flowhttpnode (service-automation/src/builtin/http-nodes.ts) and the messaging outbox (service-messaging/src/http-sender.ts). The ruling keeps the outbound contract (webhooks.mdx§6.2) as published. - So triage has two positions to choose between:
- drop the branch in core's exported verifier. That narrows a published
@objectstack/coreexport's accept set and touchesdomain:engine. - or refuse
scheme === 'body-only'at the trigger-api door. That stays indomain:services, and core keeps a branch nobody calls.
- drop the branch in core's exported verifier. That narrows a published
Serial note
PR #22822 (#22806, in the merge queue at filing) rewrites the refusal path of api-trigger.ts's handleRequest: one 401 INVALID_SIGNATURE for every post that does not verify, with the cause logged. This card builds on that path once it lands. A body-only post then becomes one more logged signature-* cause behind the same answer.
Done when
- A post signed only in the body-only form is refused with the uniform
401 INVALID_SIGNATURE, and nothing is enqueued. - The deprecation warning is gone.
- Pins at the door:
- the body-only form, valid for the secret, is refused;
- CONTROL: the timestamped form inside the window is accepted;
- CONTROL: outside the window it is refused;
- CONTROL: outbound
signHttpBodyoutput is unchanged.
- The
ITriggerApiService.handleInboundHookdocblock (packages/spec) names the timestamped form as the only accepted one. Today its verification bullet names only the body-only form; see the relay6108557878on [PM seat] domain:spec — 🟢 huangyiirene · session_01YRp5z4kK6vgYkrdH97ztsy #6017.
Duplicate check
Same two MCP search_issues queries as #22825, closed cards included: 4 and 5 hits. None is this card. The nearest are #22769 (closed: it added the timestamped form and the deprecation warning) and #22806 (open: the uniform refusal).
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 11, 2026
Triage: first grade,
enhancement·security·priority:p2·domain:services·area:workflow,pm:blockedon #22825. Position: refuse at the trigger-api door; core's verifier keeps both formsTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T11:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.verifyHttpSignature(packages/core/src/security/http-signature.ts) is, by its own docblock, "the receiver's half of both forms". It is the one scheme definition for every ObjectStack sender, and our outbound senders still sign body-only (signHttpBody), which the ruling keeps as published (§6.2).@objectstack/coreexport unable to verify the signatures our own outbound sends. It would also narrow that export's accept set (Clause-②: yes) for no inbound gain.scheme === 'body-only'. This stays indomain:services, and core's branch is not dead: it is the receiver half of the outbound form.Blocked-by: #22825(the guidance) stands, as the ruling sets: no period of time, only that merge.401 INVALID_SIGNATURE. If fix(trigger-api,spec): one answer for every inbound hook post that does not verify #22822 has not landed when inbound webhook guidance: every place that teaches a sender teaches the timestampedt=…,v1=…signature only (ruling B on #22805, step 1) #22825 merges, the claimant waits for it. That is ordering in the same function, ⛔ not a new block.ITriggerApiService.handleInboundHook, relay6108557878on [PM seat] domain:spec — 🟢 huangyiirene · session_01YRp5z4kK6vgYkrdH97ztsy #6017) rides the same PR under the exception path: describe-only text, with an at-tier contract review. That is the [finding] trigger-api: the inbound hook matches the flow and hook id before it verifies the signature, so an unsigned post's 401 versus 404 tells which flow names are armed #22806 precedent.bodyOnlyDeprecationLogged) goes with the branch at the door.Clause-②: yes. It narrows the inbound door's accept set, so the changeset states FROM → TO.objectstack-fleet commentedon Oct 11, 2026
Unlock:
pm:blocked→pm:queueTriage seat (seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T15:01Z. ⛔ Not a claim, ⛔ not a dispatch.t=…,v1=…signature only (ruling B on #22805, step 1) #22825 (the sender guidance) closedcompletedat 2026-10-11T14:40Z. Ruling B on [Decision] 入站 webhook 签名:要不要在入站门拒收不带时间戳的旧签名(sha256=正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805 waits only for that merge, "not for any period of time".401 INVALID_SIGNATURE.6108760218) stands. The refusal lives at the trigger-api door, and core'sverifyHttpSignaturekeeps both forms, because it is the receiver half of our outbound. TheITriggerApiServicedocblock rides the same PR.objectstack-fleet commentedon Oct 11, 2026
Claim: PM loop round 1 · 2026-10-11T18:27Z
Session:
session_013LbZ9MhPp1iriZEtgJqioAAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22826-refuse-body-only-signatureWorktree:
objectstack-issue-22826Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)Scope: step 3 of ruling B on #22805 (
6108492902, maintainer 「同意」), positioned by triage (6108760218): the trigger-api inbound door refuses a verdict whoseschemeisbody-only, with the uniform401 INVALID_SIGNATUREand nothing enqueued; core'sverifyHttpSignaturekeeps both forms. TheITriggerApiService.handleInboundHookdocblock rides the same PR under the exception path (describe-only). Ruling-ref:6108492902.File surface, read on
origin/main75b6d6a04:packages/triggers/trigger-api/src/api-trigger.ts: thehandleRequestverification path refusesscheme === 'body-only'as one more logged cause behind the same401;bodyOnlyDeprecationLoggedand its warning (near:86,:234,:317) go with the branch.packages/triggers/trigger-api/src/, including thehandleInboundHookmember's arm table and the member/mount parity table PR feat(trigger-api): the trigger-api service serves the inbound hook from a Request, and the self-hosted route is read from http.server first (trigger-api segment 3 of ruling A on #22757) #22873 added.packages/spec/src/contracts/trigger-api-service.ts: description text only; the verification bullet names the timestamped form as the only accepted one. ⛔ No schema shape change. Declared on [PM seat] domain:spec — 🟢 huangyiirene · session_01YRp5z4kK6vgYkrdH97ztsy #6017 in this act (it is also the carrier the at-tier review on PR feat(trigger-api): the trigger-api service serves the inbound hook from a Request, and the self-hosted route is read from http.server first (trigger-api segment 3 of ruling A on #22757) #22873 asked for)..changeset/22826-*.mdfor@objectstack/trigger-api(and@objectstack/specfor the docblock), FROM → TO.packages/core(verifyHttpSignature,signHttpBody), to the outbound senders, or to ADR-0138 (its D7 sentence is corrected at that ADR's next revision, ⛔ not here). Stop on breach; explain in the report.security. ⛔ No reproduction recipe on any public surface.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"; the default tier). Describe text on a published contract and a breaking narrowing owe aCONTRACT_REVIEW_TIERreview of record before the queue.Clause-②: no (narrowing)
yes; it is not taken, because the criterion is widening, and a narrowing is declarednowith the narrowing arm, whichcheck-changeset-no-majorreads as breaking.Responsibility:
n/a — not a defect cardThread-read: 6110352531
Serial constraints cleared, at this stamp:
t=…,v1=…signature only (ruling B on #22805, step 1) #22825 (the sender guidance, the ruling's only blocker) closedcompleted; PR fix(trigger-api,spec): one answer for every inbound hook post that does not verify #22822 ([finding] trigger-api: the inbound hook matches the flow and hook id before it verifies the signature, so an unsigned post's 401 versus 404 tells which flow names are armed #22806, the uniform refusal) and PR feat(trigger-api): the trigger-api service serves the inbound hook from a Request, and the self-hosted route is read from http.server first (trigger-api segment 3 of ruling A on #22757) #22873 (trigger-api: implement the declared inbound-hook member through the same verifier, readhttp.serverbefore the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774, thehandleInboundHookmember, same file) are both onmain(75b6d6a04).packages/triggers/trigger-api/,packages/spec/src/contracts/trigger-api-service.tsorpackages/core/src/security/http-signature.ts(every open PR's file list read).objectstack-fleet commentedon Oct 11, 2026
os-dev-report
{
"issue": 22826,
"status": "done",
"branch": "claude/issue-22826-refuse-body-only-signature",
"pr": "#22887",
"session": "session_013LbZ9MhPp1iriZEtgJqioA (in-session subagent; the parent's id)",
"premise_still_valid": true,
"summary": "The trigger-api inbound hook door refuses a verdict whose scheme is body-only, at the position triage set. A body-only x-objectstack-signature that IS the secret's HMAC of the body now gets the uniform 401 INVALID_SIGNATURE (PR #22822's envelope) and nothing is enqueued. The refusal sits right after verification, before the JSON parse and the enqueue, and is logged with one new cause, signature-body-only. The deprecation line and bodyOnlyDeprecationLogged are gone. One private function, verifyInboundSignature, holds the judgement (core verdict, then an exhaustive switch on scheme) for both handleRequest and the exported verifySignature, so that export agrees with the door. Core's verifyHttpSignature and signHttpBody are untouched. The ITriggerApiService.handleInboundHook docblock (packages/spec, describe-only) names the timestamped form as the only one accepted. The whole-repo sweep flipped the trigger-api arm/parity/uniform rows, the dogfood credential-channel signer (it drove the real door body-only and would have gone 401) and the runtime dispatcher double's verifier.",
"tests": "All at HEAD 78b9270. trigger-api:
pnpm --filter @objectstack/trigger-api exec vitest run --maxWorkers=2→ Test Files 3 passed, Tests 55 passed;pnpm --filter @objectstack/trigger-api typecheckexit 0 (tsconfig includes src/**/, tests included). runtime:pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-hooks.test.ts→ 19 passed;pnpm --filter @objectstack/runtime typecheckexit 0 ('check:test-typecheck: OK'). dogfood:pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/flow-credential-channel.dogfood.test.ts→ 10 passed;pnpm --filter @objectstack/dogfood typecheckexit 0. spec:pnpm --filter @objectstack/spec exec vitest run src/contracts/trigger-api-service.test.ts→ 1 passed;pnpm --filter @objectstack/spec typecheckexit 0;pnpm --filter @objectstack/spec check:generated→ 'All 14 generated artifacts are up to date'. ABLATION: each mutation went through scripts/ablation-replace.mjs (wrap mode, anchor 1 → 0, blob changed) inside a wrapper with trap EXIT INT TERM. Each restore wasgit checkout HEAD -- ABSPATH, proven by blob == HEAD:path (aff2541dd93b) and emptygit diff HEAD. The trigger-api suites import ./api-trigger.js and ./plugin.js relatively (source, no exports or dist resolution), so the dist preflight is not applicable; dogfood aliases @objectstack/trigger-api to source. (A) body-only accepted again: 7 failed / 48 passed, e.g. 'first post: expected { status: 202 } to deeply equal { status: 401 }', 'expected true to be false' (verifySignature), member arm table and parity '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' ]". 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. (D) reverse check: the dogfood suite with its old body-only signer against the new door: 7 failed / 3 passed, each 'expected 401 to be 202', through the real engine and ApiTrigger. Restored OK.","gates": "Re-derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 78b9270: 87 commands, a superset of the dispatch's 78 (PM-only: none). The nine extra are check-changeset-no-major (x2), check-empty-changeset (x2), release-rehearsal-clone and release-pending-publish self-tests, check:objectui-changeset, check:pm-changeset-deadline-census and check:route-envelope. All 87 ran, plus spec check:generated, and every one exited 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 unrelated packages had no dist); after a turbo cache restore the re-run exited 0.dispatch-gates --ranwith exit codes: '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)'. Changeset gates: check-adr-0087-registration '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition' [major+BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription); check-changeset-no-major: pre mode, major expected. CI on the PR: in_progress at report time (14 completed, 18 in progress, 0 failed); not waited on.","line_budget": "Ruling priced the refusal PR at about 150-300 lines. Actual: 369 changed lines (+226 / -143) over 9 files, under the 3000 human-merge threshold. Production: api-trigger.ts +67/-42, plugin.ts +3/-3, trigger-api-route-ledger.ts +2/-2, spec trigger-api-service.ts +12/-7. Tests: api-trigger.test.ts +92/-74, inbound-hook-member.test.ts +5/-4, runtime automation-hooks.test.ts +15/-10, dogfood flow-credential-channel +5/-1. Changeset +25. The overrun is the sweep's test flips.",
"files_changed": [
".changeset/22826-refuse-body-only-signature.md",
"packages/triggers/trigger-api/src/api-trigger.ts",
"packages/triggers/trigger-api/src/plugin.ts",
"packages/triggers/trigger-api/src/trigger-api-route-ledger.ts",
"packages/spec/src/contracts/trigger-api-service.ts",
"packages/triggers/trigger-api/src/api-trigger.test.ts",
"packages/triggers/trigger-api/src/inbound-hook-member.test.ts",
"packages/runtime/src/domains/automation-hooks.test.ts",
"packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts"
],
"deviations": [
"Zone 2 #2: the count is off by two. The vocabulary had six causes (unknown-flow, wrong-hook-id and four signature-), not four. Exactly one was added (signature-body-only), giving seven, five of them signature-*. 'The four-cause log line' was read as 'the cause log line pins the new cause', and the uniform table's cause test does pin it.",
"File surface: two test files outside the claim's declared surface were edited under the dispatch's whole-repo sweep instruction. packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts signed body-only against the real ApiTrigger, so its 202 expectations would have gone 401 in the required Dogfood gate (shown by reverse check D). packages/runtime/src/domains/automation-hooks.test.ts holds a member double documented as carrying the trigger's verifier; it now accepts v1 only and signs v1, a fidelity fix with no new pin. Both are named in the PR body.",
"Also within trigger-api but not named in the claim: the plugin.ts docblock and the route-ledger note ('GitHub/Stripe-style HMAC over the raw body') became false, so both were corrected (describe-only). The exported verifySignature now refuses body-only too, matching the door; it is declared in the changeset.",
"Changeset grade: @objectstack/trigger-api major, @objectstack/spec patch. Trigger-api follows the pre-mode precedent for breaking narrowings (22502/22565/22572 use major on the v18 line). Spec is patch because its docblock reaches dist/contracts/index.d.ts and the type is unchanged. ADR-0087 marker: not-required (no-migration-prescription), since no metadata moves and the remedy lives in a third-party sender. The changeset points to the published sender guidance rather than writing a rewrite mapping.",
"Local narrowing, declared: for spec only the contract test, typecheck and derived gates ran (the change is TSDoc text). For runtime and dogfood only the edited test files and each package's typecheck ran. Their full suites are CI's.",
"origin/main gained 2 commits after the base (40bc51f cli, 72b26ed metadata-protocol/spec). Neither touches packages/triggers, core/security, the edited spec file or the edited test files (measured with git diff --stat), so no merge was made before opening."
],
"mcp_calls": "0 — no MCP GitHub tools used; reads were single-card REST (gh api GET) only",
"api_writes": "2 relay strokes (POST /repos/objectstack-ai/objectstack/dispatches each, executed by fleet-write as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/22887/assignees [zhuangjianguo]; (2) this os-dev-report = POST /repos//issues/22826/comments. Labels: zero writes. git push is not REST.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: ADR-0041's next revision (governed, Tier H; beside ADR-0138 D7's correction that ruling B already assigns) · docs/adr/0041-flow-trigger-family.md line 119 still reads 'HMAC signature verification (GitHub/Stripe style)', and GitHub's style is the body-only form · noted in the PR's Acceptance notes, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
ACCEPT (seat review): PR #22887 at
78b92702d3·domain:servicesseat 2 (#21118) ·session_013LbZ9MhPp1iriZEtgJqioA· 2026-10-11T19:37ZClaim
6112250834; ruling B on #22805 (6108492902), positioned by triage (6108760218). Report6112859544, checked against GitHub, ⛔ not taken on trust. ⛔ Classes and positions only (security).Claim amendment (file surface, same round): the claim's surface grows by four files.
packages/triggers/trigger-api: theplugin.tsdocblock and thetrigger-api-route-ledger.tsnote, both made false by this change.packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts: its signer drove the real door in the retired form, so the required Dogfood gate would have gone red. The reverse check D showed it.packages/runtime/src/domains/automation-hooks.test.ts: its member double carries the trigger's verifier and now accepts the timestamped form only, a fidelity fix with no new pin.Checklist, as checked:
main, assigneezhuangjianguo. Line 1Fixes #22826, line 2Clause-②: no (narrowing); no other closing keyword.check-governed-merges: 0 of 9 paths governed, under 3,000. Zeropackages/corelines:verifyHttpSignatureandsignHttpBodyare untouched. No outbound sender, no ADR-0138 and nocontent/docs/releases/edit.verifyInboundSignatureholds the door's judgement: core's verdict, then an exhaustiveswitchonschemethat acceptsv1and refusesbody-onlywith the causesignature-body-only.handleRequestand the exportedverifySignatureboth call it, so the export answers as the door does.refuseUnverified, the uniform401 INVALID_SIGNATUREenvelope of [finding] trigger-api: the inbound hook matches the flow and hook id before it verifies the signature, so an unsigned post's 401 versus 404 tells which flow names are armed #22806.bodyOnlyDeprecationLoggedand the deprecation line are gone.switchhas no default, so a scheme core adds later fails to compile at the door until the door decides it.401with nothing enqueued, through both the mount and the member. The cause table pinssignature-body-only. Ablations: A (accept again) 7 red, B (mislabelled cause) 2 red, C (deprecation line back) 1 red, D (old signer against the new door) 7 red. Each was restored by blob.ITriggerApiService.handleInboundHook): describe-only. It names the timestamped form as the only accepted one and the body-only form as refused. The type is unchanged.objectstack-automationskill's inbound section teaches only the timestamped form, as inbound webhook guidance: every place that teaches a sender teaches the timestampedt=…,v1=…signature only (ruling B on #22805, step 1) #22825 left it. Thewebhooks.mdxsha256=sentences describe the OUTBOUND contract, which is unchanged. Nothing is owed.verifySignature's new answer.packages/corediff.@objectstack/trigger-apiis gradedmajor(@objectstack/specpatch). The gate accepts that innextpre mode, and the dev cites precedents.check-changeset-no-major's own docblock says a declared narrowing "shipsminor" during the launch window, and this lane's ADR-0131 narrowings onmainuseminor. The seat records the two readings and does not re-grade.78b92702d3: 16 success, 3 skipped, 13 in progress at this stamp, none failed. Landing waits for the run.Out of scope, noted: ADR-0041 (
docs/adr/0041-flow-trigger-family.md, near:119) still names the trigger's HMAC "GitHub/Stripe style". Carrier: that ADR's next revision, beside ADR-0138 D7's correction, which ruling B already assigns. It is governed text, ⛔ not this PR's.Landing: a narrowing and describe text on a published contract owe a
CONTRACT_REVIEW_TIERreview of record.needs:contract-reviewgoes on the PR in this act, and this seat runs the review with an isolated at-tier reviewer. On PASS with every check green, the PR goes ready and auto-merge. It is not governed and is under the human-merge threshold.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Landed and closed out ·
domain:servicesseat 2 (#21118) ·session_013LbZ9MhPp1iriZEtgJqioA· 2026-10-11T21:54Z⛔ Classes and positions only (
security).5ce6773a57, from the reviewed head78b92702d3. ACCEPT6112924906; at-tier contract review PASS6113890618,Served-tier: CONTRACT_REVIEW_TIER. Read onorigin/main: every file of the PR is byte-identical between the reviewed head and5ce6773a57.sha256=正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805 (6108492902).401 INVALID_SIGNATUREand enqueues nothing. Its server-log cause issignature-body-only.verifySignatureanswers as the door does.@objectstack/core'sverifyHttpSignatureandsignHttpBodyare unchanged, so the outbound contract keeps the body-only form.ITriggerApiService.handleInboundHookdocblock names the timestamped form as the only accepted one.Fixes #22826closed this cardcompleted. The lane shows no other closure since the merge.pm:dispatchedand the assigneezhuangjianguocome off in this act.enhancement,security,priority:p2,domain:servicesandarea:workflowstay.Generated by Claude Code