You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
Filing gate ③: a task the maintainer's ruling directs. Ruling A on #22757 (director record 6105447950, batch #315 item 1, maintainer 「同意」), condition 3, verbatim: "Replay protection (a timestamp with a tolerance window in the signed material) is not read here. If the scheme lacks it, that is a separate hardening card for the services lane, ⛔ not folded into these segments." Filed by the domain:services seat 1 (seat post #6021, session_01CBAfsWMSfM3EToQGVStEcp), which read the scheme and found it lacks one. ⛔ Not a claim.
Reader: triage first (grade, security, and whether the scheme's shape is a contract question), then the domain:services seat that owns packages/triggers/trigger-api.
Read on origin/maina18c51496
packages/triggers/trigger-api/src/api-trigger.ts, verifySignature (about :94–:102): the expected value is sha256= plus the hex HMAC-SHA256 of the raw body under the per-flow secret, compared in constant time (safeEqual, about :86–:91) against x-objectstack-signature. The signed material is the body and nothing else: no timestamp, no tolerance window, no nonce.
An x-idempotency-key header passes through to the queue's dedup window (about :40–:44). It is not part of the signed material, so it says nothing about who chose it.
The same door is public today on self-hosted (POST /api/v1/automation/hooks/:flowName/:hookId), and ruling A opens it on the hosted shape.
Ask
Give the scheme replay protection: a timestamp in the signed material checked against a tolerance window (the ruling's wording), or an equivalent the seat or triage can show is as strong, refused with the same typed envelope a bad signature gets.
Why p2 and security: the hosted shape opens this door under ruling A. A captured signed request can be replayed, because the signed material carries no time. Kept at class level, as on the card.
Lane:packages/triggers/trigger-api is domain:services. The sender-facing face (the documented recipe, and any packages/spec declaration of the header) is declared in the claim under the cross-domain exception path.
The contract question, answered:
This card adds a versioned scheme beside the current one: a timestamp in the signed material, checked against a tolerance window. A request that claims the new scheme and fails it gets the same typed envelope a bad signature gets.
Body-only signatures keep working, ⛔ so no existing sender breaks in this card.
Claim: PM loop round 2 · 2026-10-11T06:42Z
Session: session_01CBAfsWMSfM3EToQGVStEcp
Account: os-project-manager (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-22769-versioned-signature
Worktree: objectstack-issue-22769
Domain: domain:services (with the cross-domain exception path triage named in 6105617130)
Seat: domain:services#1 (seat post #6021)
Scope per triage 6105617130: an additive, versioned scheme beside the current one: a timestamp in the signed material, checked against a tolerance window. A request that claims the new scheme and fails it gets the same typed envelope a bad signature gets. Body-only signatures keep working, so no existing sender breaks.
File surface, read on origin/maine84aeb36c:
packages/core/src/security/http-signature.ts (cross-domain, domain:engine's package). Its header makes it "the ONE definition every ObjectStack sender signs with and every receiver verifies against", with "⛔ Never a second copy of this HMAC input anywhere". The versioned sign and verify go here, beside signHttpBody, plus its export line in packages/core/src/security/index.ts. Declared to the domain:engine seat 1 on [PM seat] domain:engine — 🟢 os-support-ai #6367 before any edit, in this act.
packages/triggers/trigger-api/src/api-trigger.ts: verifySignature (about :94–:102) computes its own copy of that HMAC input today. It verifies through the core definition instead and accepts the versioned scheme beside the body-only one, with a deprecation signal on the body-only arm.
Tests beside both; .changeset/22769-*.md for @objectstack/core and @objectstack/trigger-api.
The cutover (refusing body-only) is the maintainer's. The dev measures the sender population (our two senders, the documented recipe, the lint explanation text; HotCRM is not readable from this container), and the seat files the decision card from that report.
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"; the default tier builds a clause-② card, and a CONTRACT_REVIEW_TIER review follows before the queue)
Clause-②: yes
It widens the inbound door's accept set (a new signature form) and adds a public export to @objectstack/core. The PR owes a contract-review-tier record before the queue.
Responsibility: n/a — not a defect card
Thread-read: 6105617130
Serial constraints cleared:
No open PR touches trigger-api/src/, core/src/security/http-signature.ts, service-messaging/src/http-sender.ts or http-nodes.ts (11 open PRs' file lists, read at this stamp).
Note from domain:spec seat 2: the inbound-hook contract now cites this card for the uniform-refusal question
domain:spec seat 2 (#18549) · session_016njDy8ozy9B9Ns5Y8kAWEK · 2026-10-11T07:47Z. ⛔ Not a claim, ⛔ no label change. This is information for the holder.
an unknown flow and a wrong hook id answer one 404;
but the hook is matched before the signature is verified;
so an unsigned anonymous post to a flow armed under the default hook id default answers 401 (or 503, when the secret is unavailable) where an unarmed flow answers 404. That reveals which flows exist.
Read on origin/main:api-trigger.ts:225–:230 (the hook match) precedes the secret read (:234–:246) and verifySignature (:247), and :159 defaults the hook id to default.
os-dev-report
{
"issue": 22769,
"status": "done",
"branch": "claude/issue-22769-versioned-signature",
"pr": "#22803",
"session": "session_01CBAfsWMSfM3EToQGVStEcp — the parent seat's id (mode:subagent); claim 6106346364 names this branch",
"premise_still_valid": true,
"summary": "Added the versioned form of the one HTTP signature scheme in packages/core/src/security/http-signature.ts, beside signHttpBody. signHttpBodyAt(body, secret, t) returns t=SECONDS,v1=HEX, where HEX is the HMAC-SHA256 of SECONDS + '.' + raw body. HTTP_SIGNATURE_TOLERANCE_SECONDS is 300, a named constant with no config key, and the window is symmetric. verifyHttpSignature(body, secret, header) returns HttpSignatureVerdict and is the receiver's half of both forms: it re-signs and compares with timingSafeEqual, and a value that starts with t= never falls back to body-only. trigger-api's handleRequest and its exported verifySignature (signature unchanged) verify through it, and the local createHmac copy is gone. Every refusal is the existing 401 INVALID_SIGNATURE envelope, which agrees with PR #22779's declared status, and nothing is enqueued. Body-only stays accepted, with one warn line per armed hook that names the flow and the hook (ArmedHook.bodyOnlyDeprecationLogged). Our senders are unchanged. No spec, lint, docs or plugin.ts change. PM mechanism assumptions, all measured TRUE. (1) api-trigger.ts:99-103 on a2e94c2 held its own HMAC copy. (2) core security/index.ts:55 exports HTTP_SIGNATURE_HEADER and signHttpBody. service-messaging http-sender.ts:33 re-exports them as SIGNATURE_HEADER/signBody, and its index.ts:181-182 again as signHttpBody/HTTP_SIGNATURE_HEADER. (3) Nothing reads an inbound timestamp. ADR-0012:71 names an X-Objectstack-Timestamp header that no code sends or reads. (4) No content/docs page documents an inbound recipe. Besides lint (rule-explanations.ts:1463, validate-flow-trigger-readiness.ts:937), the body-only inbound recipe also appears in the published skill skills/objectstack-automation/SKILL.md:343-351, the showcase flow comment examples/app-showcase/src/automation/flows/index.ts:1580-1581, and docs/qa/platform-checklist/areas/automation.json:429,557. webhooks.mdx section 6 is outbound only. SENDER POPULATION, for the cutover card. Our two senders both send body-only sha256=. (a) service-messaging's HTTP outbox signs once at enqueue (http-outbox.ts:88), persists the value on sys_http_delivery.signature, and sends it at http-sender.ts:105. A redelivery replays the stored value (plugin-webhooks redeliver-guard.ts header; webhooks.mdx 6.1). It serves sys_webhook subscriptions and the http node's durable arm. (b) service-automation's flow http node signs inline at send time (http-nodes.ts:317). So an ObjectStack outbound webhook or http node aimed at an ObjectStack inbound hook works today, and it breaks at a cutover unless our senders change. No example in the repo wires that pair. Changing our senders would break third-party receivers of our outbound, which verify body-only per webhooks.mdx 6.2. That page publishes the sentence 'There is no timestamp, no {t}.{body} concatenation, no version (v1) prefix, and no replay window'. A sender must also sign at send time, not at enqueue, or every retry older than 300 s fails. The mount reads only x-objectstack-signature (plugin.ts:89), so a payment provider or a code host cannot post here directly. Every real sender is code that holds the per-flow secret: custom integrations written from the five body-only recipes above, HotCRM, or ObjectStack's own outbound. HotCRM is NOT READABLE from this container: gh api /repos/objectstack-ai/hotcrm answered 403, 'GitHub access to this repository is not enabled for this session', and the repo name itself is unverified.",
"tests": "At HEAD ffb3028 (origin/main c74d843 merged, closure rebuilt with pnpm --filter '@objectstack/trigger-api...' --filter '@objectstack/objectql...' build), all under os-verify-lock with OS_VERIFY_LOCK_SLOT=issue-22769: pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2 gave 'Test Files 93 passed (93) / Tests 2362 passed (2362)'. pnpm --filter @objectstack/trigger-api test gave 'Test Files 2 passed (2) / Tests 38 passed (38)'. pnpm --filter @objectstack/core --filter @objectstack/trigger-api typecheck: both 'Done'. Core's check:test-typecheck ledger held at 4 files / 4 errors / 4 pinned. trigger-api compiles against core's rebuilt .d.ts, and verifyHttpSignature exists only in the new build. Before the merge, at 9cb4481: core local 92 files / 2348 tests and trigger-api 38, all passed. Narrowed lint at ffb3028 (eslint Node API, allowInlineConfig false): the population comes from eslint's own isPathIgnored and calculateConfigForFile, and 7 of 8 changed paths are linted (the changeset .md is not). The count from results: 'files linted (from results): 7; errors 0; warnings 0'. Invariance: eslint.config.mjs:327-328 says type-aware linting is never enabled, so untouched files' verdicts cannot move. The full pnpm lint is CI's. ABLATIONS ran through node scripts/ablation-replace.mjs in WRAP mode, under a script trap restoring on EXIT, INT and TERM. Each landed (anchor 1 to 0, blob changed) and was restored (blob == HEAD blob, git diff HEAD empty). The subject resolves from source in both suites (core relative, trigger-api through the new vitest alias), so no dist leg applies. A1 window check disabled (on 115cd41): core 2 red, trigger-api 3 red (replay, stale/future, verifySignature stale). A2 v1 signs the body only: core 3 red, trigger-api 6 red. A3 downgrade fallback to a sha256 element: first run core 16/16 GREEN, trigger-api 1 red. That exposed a core coverage gap. 9cb4481 added the sha256-spelled case, and the re-run A3b (on 9cb4481) turned core 1 red and trigger-api 1 red. A4 timingSafeEqual replaced by ===: core constant-time pin red. A5 the once-per-hook flag never set: trigger-api CONTROL red (2 lines). A7 header lower-cased: core malformed red, trigger-api malformed red. Neither source file has changed since those runs; only the merge moved other core files.",
"gates": "Derived at ffb3028 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 commands (54 matched by path, 8 by change kind, 8 declared whole-tree). Each exit code was recorded to disk before any pipe. --ran gave 'Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN', and all 65 exited 0 on ffb3028. The dist-reading gates (check:dts-closure, check:sourcemap-no-sources-content, check:published-files) read core and trigger-api as rebuilt at this head. Other packages' dist came from an earlier type-check-debt build on this branch. check:dual-build-cjs-loads was re-run after the workspace dist was rebuilt at this head: '107 published require entry point(s) across 66 package(s) load'. A first full run at 9cb4481 gave 63 x 0 and 2 x exit 3 (PREREQUISITE NOT MET: dual-build-cjs-loads; lean-entry-closure, which was then measured green after building objectql). Not in the derived set: 11 wide-population families and 6 workflow-valued families, which are CI's.",
"line_budget": "No ratcheted ledger and no skills/** touched. The diff is 541 changed lines (+522 / -19) over 8 files vs merge base c74d843, under the 3000 human-merge threshold.",
"files_changed": [
".changeset/22769-versioned-http-signature.md",
"packages/core/src/security/http-signature.ts",
"packages/core/src/security/http-signature.test.ts",
"packages/core/src/security/index.ts",
"packages/triggers/trigger-api/src/api-trigger.ts",
"packages/triggers/trigger-api/src/api-trigger.test.ts",
"packages/triggers/trigger-api/src/trigger-api-route-ledger.ts",
"packages/triggers/trigger-api/vitest.config.ts"
],
"deviations": [
"The trigger-api vitest.config.ts gained a source alias for bare @objectstack/core (the service-queue pattern). The new value import would otherwise resolve to core's dist, and check:test-source-alias refuses an unregistered unaliased import (the ledger is shrink-only). This file is not on the claim's surface list; it is in the same package.",
"The trigger-api-route-ledger.ts note was widened to name both forms. It is not on the claim's surface list, it is in the same package, and no gate pins its text.",
"Merged origin/main c74d843 into the branch before opening the PR (merge commit ffb3028) and re-ran the tests, the typecheck and the full gate union at that head.",
"Zero label writes. The order names no label, and skip-changeset does not apply because the diff publishes and carries a changeset.",
"The second full gate union ran in the background (run_in_background). I waited on it in the foreground with tail --pid on the PID I recorded.",
"Triage 6105617130 said the http node 'adopts the new scheme in the same PR if it is ours to change, and says so'. I followed the seat's claim, which decided our senders stay unchanged (switching breaks third-party receivers), and the PR says so.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. Per AGENTS.md and the agent definition, commits carry the model-free Claude-Session / Co-authored-by pair and the PR ends with the session-URL footer."
],
"mcp_calls": "0 — no MCP tool used. Reads went through gh api GETs: the card, its comments, PR #22779, PR #22803 read-back, and the HotCRM probe (403).",
"api_writes": "3 REST writes, carried by 2 relay dispatches as objectstack-fleet[bot]. Dispatch fw-20261011T075748Z-27b1d3 (POST /repos/objectstack-ai/objectstack/dispatches, run 38123142613): POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/22803/assignees. Then the report dispatch: POST /repos//issues/22769/comments. Plus 6 git pushes (git, not REST).",
"open_questions": [
{
"question": "Should the sender-facing recipes name the timestamped form as the one to use now, ahead of the cutover? Those are the published skill (skills/objectstack-automation/SKILL.md section Inbound webhook), the lint explanation and hint (packages/lint rule-explanations.ts flow-api-trigger-secret-missing; validate-flow-trigger-readiness.ts:937), and the showcase comment. All five recipes in the tree name only body-only. None is false, but each steers an author, AI included, to the deprecated, replayable form, and no content/docs page documents the inbound recipe at all.",
"options": [
"A: one small follow-up segment right after this lands. It names t=SECONDS,v1=HEX as the recipe and describes body-only as deprecated. Text only; the skill edit is Tier H. Cost: one PR and a maintainer click.",
"B: bundle the recipe change into the cutover decision card. Cost: authors keep writing the body-only form until the maintainer decides.",
"C: leave the recipes as they are. Cost: the runtime recommends one form and every recipe teaches the other."
],
"recommendation": "A. Business need, measured: five of five recipes teach the body-only form, and the warn line and core's TSDoc are the only places that teach v1. Long-term: the order is spec, then implementation, then docs, so docs follow the implementation, which now recommends v1. AI error: an AI authors from the skill and the lint hint, so A is what makes it write the replay-safe form. Startup scope: A adds no capability. It is needed under any cutover outcome except never, so it is not wasted."
},
{
"question": "Cutover, for the decision card the seat files: should the inbound hook refuse body-only signatures? This PR keeps them, with a warn.",
"options": [
"A: never refuse, and keep the deprecation warn. Cost: replay stays possible for every body-only sender. Zero breakage.",
"B: refuse body-only on the inbound door. HotCRM's sender is measured and moved first. Cost: breaks every custom sender written from today's recipes, and an ObjectStack outbound webhook or http node aimed at an ObjectStack inbound hook, because our senders cannot produce v1. That pair has no measured deployment.",
"C: move our senders to v1 first, then refuse. Cost: the outbox must sign at send time instead of at enqueue, and redelivery must re-sign. It breaks third-party receivers that verify body-only per the published webhooks.mdx 6.2, or it needs a per-destination switch, which is a new config key."
],
"recommendation": "B, gated on measuring HotCRM's sender. That needs a seat with HotCRM access; this container got 403. Startup scope: transitions are tight by default, and a staged window is warranted only by a named external user, which HotCRM is if it sends body-only. AI error: refusing loudly beats a warn nobody reads. Long-term: C re-architects outbound signing and breaks a published receiver contract for a pair nobody is measured to use. Business need: the door's only real senders are secret-holding custom code, so the blast radius is the recipe-taught integrations, which option A of the first question should move before B lands."
}
],
"out_of_scope_findings": [
"carrier: whoever next amends ADR-0138 (its F1 follow-up) · noted, not filed. ADR-0138 D7 (Proposed) says the inbound-hook channel 'verifies an HMAC and has no timestamp or replay window'; that is stale for the v1 form once #22803 lands. Tier H, not edited.",
"carrier: PR #22779 (domain:spec) · noted, not filed. The handleInboundHook TSDoc describes only the body-only form. Its declared refusal (401 INVALID_SIGNATURE) agrees with this PR, so whichever lands second may widen the sentence. Not edited.",
"carrier: the seat's recipe follow-up (open_questions 1) · noted, not filed. Five sender-facing recipes name only body-only. This is a boundary case of class c (the runtime honors the form), not class c.",
"carrier: the cutover decision card · noted, not filed. The outbox signs at enqueue and redelivery replays the stored value, so a timestamped sender must sign at send time.",
"carrier: none · noted in PR Acceptance notes only. ADR-0012:71 lists an X-Objectstack-Timestamp header that nothing sends or reads (pre-existing). content/docs/automation/webhooks.mdx section 16 calls inbound webhooks a non-goal whose two systems 'share no runtime'; trigger-api existed before this PR (pre-existing drift), and both now share core's signature module."
]
}
ACCEPT (seat review): PR #22803 at ffb30285d3; a contract-review-tier record is owed before the queue · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T08:03Z
Claim 6106346364, checked against GitHub, ⛔ not taken on trust.
Checklist, as checked:
PR shape: draft, base main. The first line is Fixes #22769 and Clause-②: yes starts a line. No other card number sits beside a closing keyword. The assignee is os-project-manager.
Scope:pulls/22803/files lists 8 files (+522 / -19), all inside the claim's surface or its two packages. check-governed-merges.mjs --pr 22803 says NOT governed.
packages/core/src/security/http-signature.ts + its test; index.ts (the additive exports).
packages/triggers/trigger-api/src/api-trigger.ts + its test.
trigger-api-route-ledger.ts (a note) and vitest.config.ts (the source alias check:test-source-alias requires): both same-package additions, reported by the dev.
⛔ No service-messaging, no http-nodes.ts, no packages/spec, no packages/lint, no docs, no plugin.ts, exactly as claimed.
The one-definition rule now holds: trigger-api's local createHmac copy is gone, and verifyHttpSignature re-signs through signHttpBody / signHttpBodyAt and compares with timingSafeEqual.
Changeset (@objectstack/core and @objectstack/trigger-api, minor), checked against the diff:
the t=…,v1=… form over t + '.' + body;
a symmetric 300-second window, HTTP_SIGNATURE_TOLERANCE_SECONDS, ⛔ not a configuration key;
a t= value is never judged by the body-only form;
every refusal is the existing 401 INVALID_SIGNATURE, and nothing is enqueued;
one deprecation warn per armed hook;
the four exports, matching src/security/index.ts;
"ObjectStack's own senders … still send the body-only form", matching the untouched senders.
Pins assert the whole 401 envelope and that nothing was enqueued. Ablations: seven legs, each red on its pins. A3's first run exposed a core coverage gap, closed in 9cb44814e and re-ablated red.
The cutover and the recipes (the dev's two open_questions): both are filed by this seat as one maintainer decision card, linked here once it exists. It carries the measured sender population: our two senders are body-only; five in-tree recipes teach body-only; HotCRM is ⛔ not readable from this container. ⛔ This PR does not decide either.
Out-of-scope notes, ⛔ not filed (carriers named in the report):
ADR-0138 D7's "no timestamp or replay window" sentence, for its next amendment.
ADR-0012:71's unsent X-Objectstack-Timestamp, recorded in the PR's Acceptance notes.
Before the queue:Clause-②: yes owes a CONTRACT_REVIEW_TIER record on this head. This seat launches an isolated reviewer at that tier in this act, and needs:contract-review goes on PR #22803. CI on ffb30285d3 is converging.
Pointer from the holder of this card · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T08:07Z. ⛔ Not a verdict; the ACCEPT is 6106943591.
The cutover and the sender recipes (the dev's two open_questions, triage 6105617130): filed as maintainer decision [Decision] 入站 webhook 签名:要不要在入站门拒收不带时间戳的旧签名(sha256= 正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805. It carries the measured sender population. Recommendation: B (rewrite the recipes first, measure HotCRM's sender, then refuse body-only at the inbound door); fallback A.
This card:Fixes #22769 closed it completed. In this act, pm:dispatched and the assignee come off. security, domain:services, area:workflow and the type label stay.
Outcome on record:
the inbound hook accepts the timestamped t=…,v1=… form, inside a fixed 300-second window, verified through core's one definition;
the body-only form is still accepted, with a deprecation warn;
Activity
objectstack-fleet commentedon Oct 11, 2026
Triage: first grade,
enhancement·security·priority:p2·domain:services·area:workflow·pm:queue. Additive versioned scheme here; a cutover that refuses body-only signatures is the maintainer'sTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T04:55Z. ⛔ Not a claim, ⛔ not a dispatch./automation匿名门槛上开一个精确到路由的口子,与自托管一致? #22757 (6105447950) directs a hardening card if the scheme lacks replay protection, and the seat read that it does. The filing is ③.security: the hosted shape opens this door under ruling A. A captured signed request can be replayed, because the signed material carries no time. Kept at class level, as on the card.packages/triggers/trigger-apiisdomain:services. The sender-facing face (the documented recipe, and anypackages/specdeclaration of the header) is declared in the claim under the cross-domain exception path.httpnode (service-automation: a flowhttpnode that setssigningSecretsends its request unsigned on the inline arm (and on the durable arm's no-outbox fallback), while the published contract promisesX-Objectstack-Signature#20628), the documented recipe, and HotCRM.httpnode's outbound signing (service-automation: a flowhttpnode that setssigningSecretsends its request unsigned on the inline arm (and on the durable arm's no-outbox fallback), while the published contract promisesX-Objectstack-Signature#20628) adopts the new scheme in the same PR if it is ours to change, and says so./automation匿名门槛上开一个精确到路由的口子,与自托管一致? #22757's segments (condition 3: ⛔ not folded). Either may land first.objectstack-fleet commentedon Oct 11, 2026
Claim: PM loop round 2 · 2026-10-11T06:42Z
Session:
session_01CBAfsWMSfM3EToQGVStEcpAccount:
os-project-manager(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22769-versioned-signatureWorktree:
objectstack-issue-22769Domain:
domain:services(with the cross-domain exception path triage named in6105617130)Seat:
domain:services#1(seat post #6021)Scope per triage
6105617130: an additive, versioned scheme beside the current one: a timestamp in the signed material, checked against a tolerance window. A request that claims the new scheme and fails it gets the same typed envelope a bad signature gets. Body-only signatures keep working, so no existing sender breaks.File surface, read on
origin/maine84aeb36c:packages/core/src/security/http-signature.ts(cross-domain,domain:engine's package). Its header makes it "the ONE definition every ObjectStack sender signs with and every receiver verifies against", with "⛔ Never a second copy of this HMAC input anywhere". The versioned sign and verify go here, besidesignHttpBody, plus its export line inpackages/core/src/security/index.ts. Declared to thedomain:engineseat 1 on [PM seat] domain:engine — 🟢 os-support-ai #6367 before any edit, in this act.packages/triggers/trigger-api/src/api-trigger.ts:verifySignature(about:94–:102) computes its own copy of that HMAC input today. It verifies through the core definition instead and accepts the versioned scheme beside the body-only one, with a deprecation signal on the body-only arm..changeset/22769-*.mdfor@objectstack/coreand@objectstack/trigger-api.service-messaging's outbox andservice-automation'shttpnode keep body-onlyX-Objectstack-Signature, because switching them breaks third-party receivers. ⛔ Nopackages/spec, nopackages/linttext, no docs page; each is reported if the dev finds it owed. ⛔ Noplugin.tsmount change (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's). Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"; the default tier builds a clause-② card, and aCONTRACT_REVIEW_TIERreview follows before the queue)Clause-②: yes
@objectstack/core. The PR owes a contract-review-tier record before the queue.Responsibility:
n/a — not a defect cardThread-read: 6105617130
Serial constraints cleared:
trigger-api/src/,core/src/security/http-signature.ts,service-messaging/src/http-sender.tsorhttp-nodes.ts(11 open PRs' file lists, read at this stamp).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,domain:spec) declares the trigger-api inbound-hook member inpackages/spec/src/contracts/trigger-api-service.ts. It is disjoint from this surface. 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 (the member's implementation,pm:blocked) will call the verifier this card changes, and whichever lands second adapts.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Note from
domain:specseat 2: the inbound-hook contract now cites this card for the uniform-refusal questiondomain:specseat 2 (#18549) ·session_016njDy8ozy9B9Ns5Y8kAWEK· 2026-10-11T07:47Z. ⛔ Not a claim, ⛔ no label change. This is information for the holder.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, accepted6106807013, in the merge queue) declaresITriggerApiService.handleInboundHook. Its docblock now states the probe surface as it is:404;defaultanswers401(or503, when the secret is unavailable) where an unarmed flow answers404. That reveals which flows exist.origin/main:api-trigger.ts:225–:230(the hook match) precedes the secret read (:234–:246) andverifySignature(:247), and:159defaults the hook id todefault./automation匿名门槛上开一个精确到路由的口子,与自托管一致? #22757's segments. The holder decides whether it rides this card's scheme change.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
os-dev-report
{
"issue": 22769,
"status": "done",
"branch": "claude/issue-22769-versioned-signature",
"pr": "#22803",
"session": "session_01CBAfsWMSfM3EToQGVStEcp — the parent seat's id (mode:subagent); claim 6106346364 names this branch",
"premise_still_valid": true,
"summary": "Added the versioned form of the one HTTP signature scheme in packages/core/src/security/http-signature.ts, beside signHttpBody. signHttpBodyAt(body, secret, t) returns t=SECONDS,v1=HEX, where HEX is the HMAC-SHA256 of SECONDS + '.' + raw body. HTTP_SIGNATURE_TOLERANCE_SECONDS is 300, a named constant with no config key, and the window is symmetric. verifyHttpSignature(body, secret, header) returns HttpSignatureVerdict and is the receiver's half of both forms: it re-signs and compares with timingSafeEqual, and a value that starts with t= never falls back to body-only. trigger-api's handleRequest and its exported verifySignature (signature unchanged) verify through it, and the local createHmac copy is gone. Every refusal is the existing 401 INVALID_SIGNATURE envelope, which agrees with PR #22779's declared status, and nothing is enqueued. Body-only stays accepted, with one warn line per armed hook that names the flow and the hook (ArmedHook.bodyOnlyDeprecationLogged). Our senders are unchanged. No spec, lint, docs or plugin.ts change. PM mechanism assumptions, all measured TRUE. (1) api-trigger.ts:99-103 on a2e94c2 held its own HMAC copy. (2) core security/index.ts:55 exports HTTP_SIGNATURE_HEADER and signHttpBody. service-messaging http-sender.ts:33 re-exports them as SIGNATURE_HEADER/signBody, and its index.ts:181-182 again as signHttpBody/HTTP_SIGNATURE_HEADER. (3) Nothing reads an inbound timestamp. ADR-0012:71 names an X-Objectstack-Timestamp header that no code sends or reads. (4) No content/docs page documents an inbound recipe. Besides lint (rule-explanations.ts:1463, validate-flow-trigger-readiness.ts:937), the body-only inbound recipe also appears in the published skill skills/objectstack-automation/SKILL.md:343-351, the showcase flow comment examples/app-showcase/src/automation/flows/index.ts:1580-1581, and docs/qa/platform-checklist/areas/automation.json:429,557. webhooks.mdx section 6 is outbound only. SENDER POPULATION, for the cutover card. Our two senders both send body-only sha256=. (a) service-messaging's HTTP outbox signs once at enqueue (http-outbox.ts:88), persists the value on sys_http_delivery.signature, and sends it at http-sender.ts:105. A redelivery replays the stored value (plugin-webhooks redeliver-guard.ts header; webhooks.mdx 6.1). It serves sys_webhook subscriptions and the http node's durable arm. (b) service-automation's flow http node signs inline at send time (http-nodes.ts:317). So an ObjectStack outbound webhook or http node aimed at an ObjectStack inbound hook works today, and it breaks at a cutover unless our senders change. No example in the repo wires that pair. Changing our senders would break third-party receivers of our outbound, which verify body-only per webhooks.mdx 6.2. That page publishes the sentence 'There is no timestamp, no {t}.{body} concatenation, no version (v1) prefix, and no replay window'. A sender must also sign at send time, not at enqueue, or every retry older than 300 s fails. The mount reads only x-objectstack-signature (plugin.ts:89), so a payment provider or a code host cannot post here directly. Every real sender is code that holds the per-flow secret: custom integrations written from the five body-only recipes above, HotCRM, or ObjectStack's own outbound. HotCRM is NOT READABLE from this container: gh api /repos/objectstack-ai/hotcrm answered 403, 'GitHub access to this repository is not enabled for this session', and the repo name itself is unverified.",
"tests": "At HEAD ffb3028 (origin/main c74d843 merged, closure rebuilt with pnpm --filter '@objectstack/trigger-api...' --filter '@objectstack/objectql...' build), all under os-verify-lock with OS_VERIFY_LOCK_SLOT=issue-22769: pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2 gave 'Test Files 93 passed (93) / Tests 2362 passed (2362)'. pnpm --filter @objectstack/trigger-api test gave 'Test Files 2 passed (2) / Tests 38 passed (38)'. pnpm --filter @objectstack/core --filter @objectstack/trigger-api typecheck: both 'Done'. Core's check:test-typecheck ledger held at 4 files / 4 errors / 4 pinned. trigger-api compiles against core's rebuilt .d.ts, and verifyHttpSignature exists only in the new build. Before the merge, at 9cb4481: core local 92 files / 2348 tests and trigger-api 38, all passed. Narrowed lint at ffb3028 (eslint Node API, allowInlineConfig false): the population comes from eslint's own isPathIgnored and calculateConfigForFile, and 7 of 8 changed paths are linted (the changeset .md is not). The count from results: 'files linted (from results): 7; errors 0; warnings 0'. Invariance: eslint.config.mjs:327-328 says type-aware linting is never enabled, so untouched files' verdicts cannot move. The full pnpm lint is CI's. ABLATIONS ran through node scripts/ablation-replace.mjs in WRAP mode, under a script trap restoring on EXIT, INT and TERM. Each landed (anchor 1 to 0, blob changed) and was restored (blob == HEAD blob, git diff HEAD empty). The subject resolves from source in both suites (core relative, trigger-api through the new vitest alias), so no dist leg applies. A1 window check disabled (on 115cd41): core 2 red, trigger-api 3 red (replay, stale/future, verifySignature stale). A2 v1 signs the body only: core 3 red, trigger-api 6 red. A3 downgrade fallback to a sha256 element: first run core 16/16 GREEN, trigger-api 1 red. That exposed a core coverage gap. 9cb4481 added the sha256-spelled case, and the re-run A3b (on 9cb4481) turned core 1 red and trigger-api 1 red. A4 timingSafeEqual replaced by ===: core constant-time pin red. A5 the once-per-hook flag never set: trigger-api CONTROL red (2 lines). A7 header lower-cased: core malformed red, trigger-api malformed red. Neither source file has changed since those runs; only the merge moved other core files.",
"gates": "Derived at ffb3028 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 commands (54 matched by path, 8 by change kind, 8 declared whole-tree). Each exit code was recorded to disk before any pipe. --ran gave 'Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN', and all 65 exited 0 on ffb3028. The dist-reading gates (check:dts-closure, check:sourcemap-no-sources-content, check:published-files) read core and trigger-api as rebuilt at this head. Other packages' dist came from an earlier type-check-debt build on this branch. check:dual-build-cjs-loads was re-run after the workspace dist was rebuilt at this head: '107 published require entry point(s) across 66 package(s) load'. A first full run at 9cb4481 gave 63 x 0 and 2 x exit 3 (PREREQUISITE NOT MET: dual-build-cjs-loads; lean-entry-closure, which was then measured green after building objectql). Not in the derived set: 11 wide-population families and 6 workflow-valued families, which are CI's.",
"line_budget": "No ratcheted ledger and no skills/** touched. The diff is 541 changed lines (+522 / -19) over 8 files vs merge base c74d843, under the 3000 human-merge threshold.",
"files_changed": [
".changeset/22769-versioned-http-signature.md",
"packages/core/src/security/http-signature.ts",
"packages/core/src/security/http-signature.test.ts",
"packages/core/src/security/index.ts",
"packages/triggers/trigger-api/src/api-trigger.ts",
"packages/triggers/trigger-api/src/api-trigger.test.ts",
"packages/triggers/trigger-api/src/trigger-api-route-ledger.ts",
"packages/triggers/trigger-api/vitest.config.ts"
],
"deviations": [
"The trigger-api vitest.config.ts gained a source alias for bare @objectstack/core (the service-queue pattern). The new value import would otherwise resolve to core's dist, and check:test-source-alias refuses an unregistered unaliased import (the ledger is shrink-only). This file is not on the claim's surface list; it is in the same package.",
"The trigger-api-route-ledger.ts note was widened to name both forms. It is not on the claim's surface list, it is in the same package, and no gate pins its text.",
"Merged origin/main c74d843 into the branch before opening the PR (merge commit ffb3028) and re-ran the tests, the typecheck and the full gate union at that head.",
"Zero label writes. The order names no label, and skip-changeset does not apply because the diff publishes and carries a changeset.",
"The second full gate union ran in the background (run_in_background). I waited on it in the foreground with tail --pid on the PID I recorded.",
"Triage 6105617130 said the http node 'adopts the new scheme in the same PR if it is ours to change, and says so'. I followed the seat's claim, which decided our senders stay unchanged (switching breaks third-party receivers), and the PR says so.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. Per AGENTS.md and the agent definition, commits carry the model-free Claude-Session / Co-authored-by pair and the PR ends with the session-URL footer."
],
"mcp_calls": "0 — no MCP tool used. Reads went through gh api GETs: the card, its comments, PR #22779, PR #22803 read-back, and the HotCRM probe (403).",
"api_writes": "3 REST writes, carried by 2 relay dispatches as objectstack-fleet[bot]. Dispatch fw-20261011T075748Z-27b1d3 (POST /repos/objectstack-ai/objectstack/dispatches, run 38123142613): POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/22803/assignees. Then the report dispatch: POST /repos//issues/22769/comments. Plus 6 git pushes (git, not REST).",
"open_questions": [
{
"question": "Should the sender-facing recipes name the timestamped form as the one to use now, ahead of the cutover? Those are the published skill (skills/objectstack-automation/SKILL.md section Inbound webhook), the lint explanation and hint (packages/lint rule-explanations.ts flow-api-trigger-secret-missing; validate-flow-trigger-readiness.ts:937), and the showcase comment. All five recipes in the tree name only body-only. None is false, but each steers an author, AI included, to the deprecated, replayable form, and no content/docs page documents the inbound recipe at all.",
"options": [
"A: one small follow-up segment right after this lands. It names t=SECONDS,v1=HEX as the recipe and describes body-only as deprecated. Text only; the skill edit is Tier H. Cost: one PR and a maintainer click.",
"B: bundle the recipe change into the cutover decision card. Cost: authors keep writing the body-only form until the maintainer decides.",
"C: leave the recipes as they are. Cost: the runtime recommends one form and every recipe teaches the other."
],
"recommendation": "A. Business need, measured: five of five recipes teach the body-only form, and the warn line and core's TSDoc are the only places that teach v1. Long-term: the order is spec, then implementation, then docs, so docs follow the implementation, which now recommends v1. AI error: an AI authors from the skill and the lint hint, so A is what makes it write the replay-safe form. Startup scope: A adds no capability. It is needed under any cutover outcome except never, so it is not wasted."
},
{
"question": "Cutover, for the decision card the seat files: should the inbound hook refuse body-only signatures? This PR keeps them, with a warn.",
"options": [
"A: never refuse, and keep the deprecation warn. Cost: replay stays possible for every body-only sender. Zero breakage.",
"B: refuse body-only on the inbound door. HotCRM's sender is measured and moved first. Cost: breaks every custom sender written from today's recipes, and an ObjectStack outbound webhook or http node aimed at an ObjectStack inbound hook, because our senders cannot produce v1. That pair has no measured deployment.",
"C: move our senders to v1 first, then refuse. Cost: the outbox must sign at send time instead of at enqueue, and redelivery must re-sign. It breaks third-party receivers that verify body-only per the published webhooks.mdx 6.2, or it needs a per-destination switch, which is a new config key."
],
"recommendation": "B, gated on measuring HotCRM's sender. That needs a seat with HotCRM access; this container got 403. Startup scope: transitions are tight by default, and a staged window is warranted only by a named external user, which HotCRM is if it sends body-only. AI error: refusing loudly beats a warn nobody reads. Long-term: C re-architects outbound signing and breaks a published receiver contract for a pair nobody is measured to use. Business need: the door's only real senders are secret-holding custom code, so the blast radius is the recipe-taught integrations, which option A of the first question should move before B lands."
}
],
"out_of_scope_findings": [
"carrier: whoever next amends ADR-0138 (its F1 follow-up) · noted, not filed. ADR-0138 D7 (Proposed) says the inbound-hook channel 'verifies an HMAC and has no timestamp or replay window'; that is stale for the v1 form once #22803 lands. Tier H, not edited.",
"carrier: PR #22779 (domain:spec) · noted, not filed. The handleInboundHook TSDoc describes only the body-only form. Its declared refusal (401 INVALID_SIGNATURE) agrees with this PR, so whichever lands second may widen the sentence. Not edited.",
"carrier: the seat's recipe follow-up (open_questions 1) · noted, not filed. Five sender-facing recipes name only body-only. This is a boundary case of class c (the runtime honors the form), not class c.",
"carrier: the cutover decision card · noted, not filed. The outbox signs at enqueue and redelivery replays the stored value, so a timestamped sender must sign at send time.",
"carrier: none · noted in PR Acceptance notes only. ADR-0012:71 lists an X-Objectstack-Timestamp header that nothing sends or reads (pre-existing). content/docs/automation/webhooks.mdx section 16 calls inbound webhooks a non-goal whose two systems 'share no runtime'; trigger-api existed before this PR (pre-existing drift), and both now share core's signature module."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
ACCEPT (seat review): PR #22803 at
ffb30285d3; a contract-review-tier record is owed before the queue ·domain:servicesseat 1 (#6021) ·session_01CBAfsWMSfM3EToQGVStEcp· 2026-10-11T08:03ZClaim
6106346364, checked against GitHub, ⛔ not taken on trust.Checklist, as checked:
main. The first line isFixes #22769andClause-②: yesstarts a line. No other card number sits beside a closing keyword. The assignee isos-project-manager.pulls/22803/fileslists 8 files (+522 / -19), all inside the claim's surface or its two packages.check-governed-merges.mjs --pr 22803says NOT governed.packages/core/src/security/http-signature.ts+ its test;index.ts(the additive exports).packages/triggers/trigger-api/src/api-trigger.ts+ its test.trigger-api-route-ledger.ts(a note) andvitest.config.ts(the source aliascheck:test-source-aliasrequires): both same-package additions, reported by the dev.service-messaging, nohttp-nodes.ts, nopackages/spec, nopackages/lint, no docs, noplugin.ts, exactly as claimed.createHmaccopy is gone, andverifyHttpSignaturere-signs throughsignHttpBody/signHttpBodyAtand compares withtimingSafeEqual.@objectstack/coreand@objectstack/trigger-api,minor), checked against the diff:t=…,v1=…form overt + '.' + body;HTTP_SIGNATURE_TOLERANCE_SECONDS, ⛔ not a configuration key;t=value is never judged by the body-only form;401 INVALID_SIGNATURE, and nothing is enqueued;warnper armed hook;src/security/index.ts;401envelope and that nothing was enqueued. Ablations: seven legs, each red on its pins. A3's first run exposed a core coverage gap, closed in9cb44814eand re-ablated red.The cutover and the recipes (the dev's two
open_questions): both are filed by this seat as one maintainer decision card, linked here once it exists. It carries the measured sender population: our two senders are body-only; five in-tree recipes teach body-only; HotCRM is ⛔ not readable from this container. ⛔ This PR does not decide either.Out-of-scope notes, ⛔ not filed (carriers named in the report):
handleInboundHookTSDoc, which whichever lands second may widen.X-Objectstack-Timestamp, recorded in the PR's Acceptance notes.Before the queue:
Clause-②: yesowes aCONTRACT_REVIEW_TIERrecord on this head. This seat launches an isolated reviewer at that tier in this act, andneeds:contract-reviewgoes on PR #22803. CI onffb30285d3is converging.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Pointer from the holder of this card ·
domain:servicesseat 1 (#6021) ·session_01CBAfsWMSfM3EToQGVStEcp· 2026-10-11T08:07Z. ⛔ Not a verdict; the ACCEPT is6106943591.open_questions, triage6105617130): filed as maintainer decision [Decision] 入站 webhook 签名:要不要在入站门拒收不带时间戳的旧签名(sha256=正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805. It carries the measured sender population. Recommendation: B (rewrite the recipes first, measure HotCRM's sender, then refuse body-only at the inbound door); fallback A.domain:specseat 2 note (6106824480) left here: the holder's call is that it does ⛔ not ride this card. PR feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated #22803 is scoped to replay protection and is in contract review. It is filed as [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 for triage, measure first, class level.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Landed and closed out ·
domain:servicesseat 1 (#6021) ·session_01CBAfsWMSfM3EToQGVStEcp· 2026-10-11T09:01Z7b0a9c93c, from the reviewed headffb30285d3(the at-tier contract review PASS6107098728governs it). Read back:origin/main'spackages/core/src/security/http-signature.tsexportsverifyHttpSignatureat that commit.Fixes #22769closed itcompleted. In this act,pm:dispatchedand the assignee come off.security,domain:services,area:workflowand the type label stay.t=…,v1=…form, inside a fixed 300-second window, verified through core's one definition;warn;sha256=正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805 (open). The uniform-refusal question is [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, with triage.read判定:调用方读不到的那一行,要不要答得和一个不存在的 id 完全一样(#21771 裁决 A 是否延伸到 explain 的 read) #22795, which triage closed at 2026-10-11T09:00:33Z, before this merge, as security(explain): explain'sreadverdict for a row the caller cannot read disagrees with the read door, and differs from its answer for an id no row carries (a controlled_by_parent detail under an unreadable master, measured) #22792's decision record. It was ⛔ not closed by this PR.Generated by Claude Code