Skip to content

feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated - #22803

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22769-versioned-signature
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22769-versioned-signature

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22769

Clause-②: yes

The inbound flow hook (POST /api/v1/automation/hooks/:flowName/:hookId) now accepts a second, versioned form of x-objectstack-signature. Its timestamp is inside the signed material and is checked against a fixed five-minute tolerance window. The body-only form is still accepted and now logs a deprecation signal. Our senders are unchanged. This is the additive scheme from triage's answer (6105617130). Refusing body-only signatures (the cutover) is the maintainer's decision and is not in this PR.

What changes

  • One definition, in core. packages/core/src/security/http-signature.ts gains three things beside signHttpBody:
    • signHttpBodyAt(body, secret, timestampSeconds) returns t=SECONDS,v1=HEX. HEX is the lowercase hex HMAC-SHA256, under the secret, of the string SECONDS, a full stop, and then the raw body bytes (the Stripe shape).
    • HTTP_SIGNATURE_TOLERANCE_SECONDS = 300. It is a named constant, ⛔ not a configuration key.
    • verifyHttpSignature(body, secret, header) returns HttpSignatureVerdict: { valid: true, scheme: 'v1' | 'body-only' }, or { valid: false, reason: 'absent' | 'malformed' | 'mismatch' | 'outside-window' }. It is the receiver's half of both forms. It re-signs through signHttpBody / signHttpBodyAt and compares with timingSafeEqual, so no receiver holds a second copy of the HMAC input. The window is symmetric: a t more than 300 s ahead of the clock is refused too.
    • All three, and the type, are exported from @objectstack/core (src/security/index.ts). This is an additive public surface.
  • trigger-api verifies through it. ApiTrigger.handleRequest and the exported verifySignature (same signature, still returns boolean) call verifyHttpSignature. The local createHmac copy of the signed material is gone. Every refusal is the existing 401 envelope { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } }, the status PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779 declares for the inbound-hook member, and nothing is enqueued.
  • Deprecation signal on the body-only form: one warn line per armed hook (ArmedHook.bodyOnlyDeprecationLogged). It names the flow and the hook, spells out the timestamped form, and states the window. Re-arming the hook (stop/start, which the engine does on a flow update) logs it once more. A sender that keeps posting cannot flood the log.
  • packages/triggers/trigger-api/vitest.config.ts aliases bare @objectstack/core to source, with an anchored find, the same way service-queue does. Without the alias, the new value import would make these pins judge core's dist/, and check:test-source-alias would refuse the unregistered import.
  • The route-ledger note (trigger-api-route-ledger.ts) names both forms.
  • ⛔ Not touched: service-messaging's outbox, service-automation's http node, packages/spec, packages/lint, docs, and trigger-api/src/plugin.ts.

Why this shape

  • One header, with the form told apart by its prefix (sha256= against t=). The self-hosted mount already passes the one header through unchanged, so plugin.ts needs no change. PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779's transport-neutral member says "Anything a later signing scheme adds (a timestamp, for one) arrives in the same forwarded request, so this signature gains no field for it", and this shape keeps that true. A separate timestamp header would need a mount change and a new handleRequest input, and this card's scope excludes both. ADR-0041 names "GitHub/Stripe style", and this is the Stripe grammar.
  • A strict grammar, with no fallback. A value that starts with t= is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a stale t, it is refused. It never drops back to the body-only check. The grammar is: a canonical decimal t of at most 15 digits, one 64-digit lowercase hex v1, and exactly those two elements in that order. Verification re-signs the value and compares the whole thing, so any other spelling is refused.
  • Nothing speculative: no multiple v1 entries for key rotation, no v0, no configuration key, no new header.

Tests

On HEAD ffb30285d, after merging origin/main c74d84399 and rebuilding (pnpm --filter '@objectstack/trigger-api...' --filter '@objectstack/objectql...' build):

  • pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2: Test Files 93 passed, Tests 2362 passed.
  • pnpm --filter @objectstack/trigger-api test: Test Files 2 passed, Tests 38 passed.
  • pnpm --filter @objectstack/core --filter @objectstack/trigger-api typecheck: both Done. Core's check:test-typecheck held its ledger: 4 files, 4 errors, 4 pinned signatures, no growth. trigger-api compiles against core's rebuilt .d.ts, and verifyHttpSignature exists only in the new build.

The pins (every refusal asserts the whole 401 INVALID_SIGNATURE envelope and that nothing was enqueued):

  • api-trigger.test.ts, "the timestamped signature form": the sender's value is computed with createHmac straight from the recipe, not through core.
    • A fresh timestamped post is accepted (202, enqueued, the flow runs, no deprecation line).
    • A replay outside the window is refused: the exact post that was accepted is sent again once the clock is 301 s later.
    • A stale timestamp that the HMAC genuinely covers is refused, and so is a future one past the window. The two edges are accepted.
    • A stale timestamp carried with a valid body-only signature is refused, in both the v1= and the sha256= spelling.
    • Malformed values are refused with the same envelope a bad signature gets.
    • A wrong secret and a different body are refused.
    • CONTROL: a body-only post is still accepted, and the deprecation line is logged once per armed hook.
  • http-signature.test.ts: literal-value pins of the wire form, the window edges, the replay, no fallback, the malformed set, and trimming. A constant-time pin wraps node:crypto's timingSafeEqual and asserts that it makes the one decision for every well-formed value of either form.

Ablations. Each mutation went through node scripts/ablation-replace.mjs in WRAP mode. Every one landed (anchor 1 to 0, blob changed) and was restored: the blob equals the HEAD blob and git diff HEAD is empty. A script trap restored on EXIT, INT and TERM. Both suites resolve the subject from source: core's test imports it relatively, and trigger-api through the alias. So no dist/ leg applies, and the trigger-api reds below show the alias reaching source. Runs on 115cd41f4, except A3b on 9cb44814e. Neither source file has changed since.

# Mutation Red
A1 window check disabled core: edges, replay · trigger-api: replay, stale/future, verifySignature stale
A2 v1 signs the body alone, not t.body core: literal pins, "signs the timestamp", no-fallback · trigger-api: fresh, replay, stale, no-fallback, CONTROL, verifySignature
A3b a t= value falls back to a sha256 element core: no-fallback · trigger-api: "stale timestamp with a valid body-only signature"
A4 timingSafeEqual replaced by === core: constant-time pin
A5 the once-per-hook flag never set trigger-api: CONTROL (two lines, not one)
A7 header lower-cased before parsing core: malformed set · trigger-api: malformed envelope

The first A3 run left core green, which showed that core's suite did not cover the sha256-spelled downgrade. 9cb44814e added that case, and A3b turned core red as well.

Gates. The list was derived on this branch at ffb30285d with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, which gave 65 commands. Each command's exit code was recorded to disk before any pipe. --ran reconciled them: "Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN." All 65 exited 0 on ffb30285d, including check:test-source-alias ("73 packages with tests scanned; 60 registered …") and check:nul-bytes.

  • Read on that run: the dist/-reading gates (check:dts-closure, check:sourcemap-no-sources-content, check:published-files) swept core and trigger-api as rebuilt at this head. Other packages' dist/ came from an earlier build of this branch.
  • check:dual-build-cjs-loads was re-run after the workspace dist/ had been rebuilt at this head: "107 published require entry point(s) across 66 package(s) load".

Lint (narrowed, not the repo scan). I ran eslint over this diff's files on ffb30285d, with no inline config.

  • Which files are linted is eslint's own answer, not mine: for each changed path, ESLint#isPathIgnored and calculateConfigForFile say whether it is linted. 7 of 8 are; the changeset .md is not.
  • The count comes from the results array: "files linted (from results): 7; errors 0; warnings 0".
  • The narrowing cannot hide a verdict on an untouched file. eslint.config.mjs never enables type-aware linting (its own header: "no parserOptions.project, no typed @typescript-eslint rules"), so no untouched file's verdict depends on these edits. The full pnpm lint is CI's.

Acceptance notes

None of these is filed. Each is noted for the seat.

  • ADR-0138 D7 (Proposed; docs/adr/0138-…md, "Partner webhooks are served today by the signed inbound-hook channel (verifySignature), which verifies an HMAC and has no timestamp or replay window") goes stale for the versioned form once this lands. This is a Tier H surface and is not edited here. Carrier: whoever next amends ADR-0138, alongside its F1 follow-up.
  • ADR-0012 §"What plugin-webhooks already provides" lists an X-Objectstack-Timestamp header. No sender in this tree sends it, and no code reads it. This is pre-existing and was not changed.
  • PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779 (domain:spec, open): the handleInboundHook TSDoc describes only the body-only form. Its refusal status agrees with this PR (401 INVALID_SIGNATURE). Whichever lands second may widen that sentence. ⛔ Not edited here.
  • Every sender-facing recipe in the tree names only the body-only form: skills/objectstack-automation/SKILL.md §Inbound webhook, packages/lint rule-explanations.ts (flow-api-trigger-secret-missing) and the validate-flow-trigger-readiness.ts hint, the examples/app-showcase inbound-flow comment, and docs/qa/platform-checklist/areas/automation.json. None of them becomes false, because body-only is still accepted. They do steer an author to the deprecated form, so that question goes to the seat in the report.
  • The deprecation signal reaches the operator, not the sender. A sender-facing signal (a response header) would need the mount to forward headers, which is a plugin.ts change and is out of scope.
  • Signing at enqueue cannot carry a window. The outbox signs once at enqueue and persists the value, and a redelivery replays it (redeliver-guard.ts header; webhooks.mdx §6.1). A sender adopting the timestamped form must sign at send time, or every retry older than 300 s fails. This is recorded for the cutover decision.

Generated by Claude Code

… a tolerance window, verified through core

Adds the versioned form of the one HTTP signature scheme beside the body-only
one, in packages/core/src/security/http-signature.ts:
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, refused once t is more
than HTTP_SIGNATURE_TOLERANCE_SECONDS (300) from the receiver's clock.
verifyHttpSignature is the receiver's half of both forms; it re-signs through
signHttpBody / signHttpBodyAt and compares in constant time.

trigger-api's inbound hook verifies through it instead of computing its own
copy of the HMAC input. Body-only signatures stay accepted, with a warn line
once per armed hook naming the flow and the hook. Our senders are unchanged.

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…w, and the body-only control

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…lled downgrade in core

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

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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/core, @objectstack/trigger-api, touching 13 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/triggers/trigger-api/vitest.config.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/vitest.config.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 — 27 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 680a86b4c55dcb6255151d9a038a32208400f66c → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 680a86b4c55dcb6255151d9a038a32208400f66c

⚠️ 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 680a86b4c55dcb6255151d9a038a32208400f66c → 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: ffb30285d37c5d322d20e29cff5db8e3146cdf88
Local-runs: none

Inputs read: card #22769 (body and every comment except the dispatching seat's own conclusion, which this review did not open), PR #22803 (body, 8-file list, net diff against main at the head), the check-runs on the head, director record 6105447950 on #22757 (condition 3), PR #22779's head packages/spec/src/contracts/trigger-api-service.ts (declared refusal statuses), and content/docs/automation/webhooks.mdx section 6 on main. Package manifests, trigger-api/src/index.ts and the full api-trigger.ts were read at the head through the contents API. Nothing was checked out, built or run.

① Derived judgments

  1. Inbound-hook accept set widens — x-objectstack-signature now also accepts t=SECONDS,v1=HEX, HEX being the lowercase hex HMAC-SHA256 of SECONDS.body under the per-flow secret, with t within 300 s of the receiver's clock in either direction. RIGHT: this is condition 3 of 6105447950 verbatim (a timestamp inside the signed material, checked against a tolerance window), in the additive shape triage 6105617130 answered. A symmetric window is right: a far-future t is refused too.
  2. Body-only accept set is byte-equal to before. Old path: header.trim() compared in constant time against sha256= plus the utf8 HMAC hex. New path: trim, startsWith('sha256='), constant-time compare against signHttpBody, the same HMAC. Case variants and other prefixes were refused before and are refused now. RIGHT: no existing sender breaks, which is triage's CONTROL pin.
  3. No downgrade path. A value starting with t= is judged by the timestamped grammar alone (canonical decimal t of at most 15 digits, one 64-digit lowercase hex v1, exactly those two in that order); a body-only HMAC re-labelled with a fresh t, in either the v1= or the sha256= spelling, is refused (mismatch or malformed). RIGHT: this is what keeps the new form from being a relabelling of the replayable one, and the core suite now pins the sha256=-spelled downgrade the first A3 ablation showed uncovered.
  4. Refusal envelope agrees with the declared contract. Inside the timestamped arm the HMAC is compared first and the window second; to the sender every refusal is the one 401 INVALID_SIGNATURE envelope and nothing is enqueued; reason stays internal. PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779's handleInboundHook docblock declares 401 INVALID_SIGNATURE for a missing or wrong signature and leaves 404 RESOURCE_NOT_FOUND, 503 SERVICE_UNAVAILABLE, 400 INVALID_REQUEST and 503 ENQUEUE_FAILED untouched; this diff changes none of them. RIGHT. The docblock's sentence that names only the sha256= form is prose on an open PR head, not a status disagreement; see ③.
  5. One definition, no second copy. trigger-api's local createHmac copy of the signed material is gone; both ApiTrigger.handleRequest and the public export verifySignature (signature unchanged, still boolean) verify through core's verifyHttpSignature; safeEqual remains for the constant-time hook-id compare (api-trigger.ts:249 at the head). RIGHT: the core module's own header forbids a second copy of this HMAC input, and the form is defined at the producer side in core, not tolerated as a consumer shim (Prime Directive Add comprehensive test suite for Zod schema validation #12).
  6. @objectstack/core public surface widens by four names — HTTP_SIGNATURE_TOLERANCE_SECONDS (300, a named constant and not a configuration key), signHttpBodyAt, verifyHttpSignature, and the type HttpSignatureVerdict — all additive, nothing renamed or removed, service-messaging's re-exports untouched. RIGHT. The new value import into trigger-api adds no undeclared dependency: @objectstack/trigger-api's manifest at the head already lists @objectstack/core: workspace:*, and core does not depend on trigger-api, so no cycle and nothing for tsup to bundle.
  7. Outbound contract untouched. No sender changed (the messaging outbox and the flow http node are not in the file list), so webhooks.mdx section 6.1 ("no timestamp, no {t}.{body} concatenation, no version (v1) prefix, and no replay window") and the section 6.2 receiver recipe stay true, and the core TSDoc says so. RIGHT, and it is why the dev's departure from triage's "the http node adopts the new scheme in the same PR" is the correct call: adopting it would falsify the published receiver contract for third-party receivers, and section 6.1's sign-once-at-enqueue plus byte-identical retry cannot carry a 300 s window at all. The claim 6106346364 ordered senders unchanged; the PR and the changeset say so.
  8. Deprecation signal. One warn per armed hook on its first body-only accept, reset by re-arming, naming the flow, the hook and the replacement form, with no tracker number in the runtime string. RIGHT level: a functional signal, not a durability loss; operator-facing, since a sender-facing header would need the mount change the scope excludes.
  9. Test wiring. trigger-api's vitest.config.ts gains an anchored array-form alias of bare @objectstack/core to core/src/index.ts, so the trigger-api pins judge the source in the checkout and check:test-source-alias has no unregistered unaliased import to refuse (trigger-api is absent from that ledger). Core's constant-time pin mocks node:crypto's timingSafeEqual and asserts exactly one compare per well-formed value of either form. Both files sit outside the claim's list but inside the claimed package, and both were reported. RIGHT.
  10. Within-window replay remains possible for up to 300 s unless the sender sets x-idempotency-key. That is the ruling's scheme (a window, the Stripe shape), the PR claims nothing stronger, and the changeset states it. RIGHT as scoped.
  11. Route-ledger note widened to name both forms; no script pins its text (the only reference in scripts/ names the conformance test, which passes). RIGHT.
  12. Governed surfaces: none of the 8 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md or CLAUDE.md; Governed Surface Queue Guard is green. The record this PR owes is the clause-② one the claim named, which this is.

② Semver level

  • .changeset/22769-versioned-http-signature.md: @objectstack/core: minor, @objectstack/trigger-api: minor. Matches what the diff publishes: core adds four exports (additive); trigger-api widens the accept set of a public export (verifySignature, exported from src/index.ts) and of the hook. Nothing removed, renamed or narrowed, so no major, no breaking marker and no ADR-0087 disposition owed. The body states the wire form, the refusals, what stays unchanged, and the sender adoption recipe. Check Changeset is green.
  • Clause-②: yes is present at line start in the PR body and in the changeset, the fixed spelling clause2-line.mjs reads; no arm, and yes with no arm is the widening reading, which takes at least minor. RIGHT: the card widens an accept set and a public surface and narrows nothing, so no (narrowing) arm is owed.
  • Not skip-changeset: right, two released packages publish.

③ Boundary flags

Dev deviations, all seven answered:

  1. vitest alias outside the claim's file list — accepted; same package, mechanically required, correct form (①9).
  2. Route-ledger note widened — accepted; same package, no gate pins it (①11).
  3. origin/main c74d84399 merged before the PR and the suite, typecheck and gate union re-run at the merge head — accepted; the head under review is that merge commit and CI judges it.
  4. Zero label writes — accepted; needs:contract-review on the PR is another actor's and this record is what it asks for.
  5. Background gate run waited on by a recorded PID — accepted.
  6. Senders left unchanged against triage's conditional wording — accepted and judged right (①7); the claim decided it.
  7. Model-free commit trailers and the session-URL PR footer — accepted; that is the form AGENTS.md states and the PR body carries it.

Open questions, both escalated to the owning seat (neither is this diff's to answer):

  1. Sender-facing recipes (skills/objectstack-automation/SKILL.md Inbound webhook, the packages/lint explanation and hint, the showcase comment, the QA checklist row) teach only the body-only form. Escalated: file the text-only follow-up segment now rather than bundling it into the cutover card; the skill path is Tier H and lands by the maintainer's word. Not a defect of this PR, whose claim excluded lint, docs and skills.
  2. Cutover (refusing body-only) — escalated: the maintainer's decision by triage 6105617130; the seat files the decision card with the population the report measured (both ObjectStack senders are body-only; enqueue-time signing cannot carry a window; HotCRM unreadable from the dev's container).

Out-of-scope findings, carriers named by the dev and accepted: ADR-0138 D7 going stale (Tier H, its F1 follow-up), PR #22779's docblock naming only sha256= (whichever lands second widens the sentence), ADR-0012's X-Objectstack-Timestamp line and webhooks.mdx section 16's non-goal wording (pre-existing drift, acceptance notes only).

One flag the dev's report does not name, escalated to the owning seat: the domain:spec note 6106824480 on the card hands the holder the uniform-refusal question (an unsigned post to a flow armed on the default hook id answers 401/503 where an unarmed flow answers 404), and PR #22779's docblock points at #22769 for it. This PR closes #22769 without touching that order, which is within condition 3 and the card's Ask. Before the card closes, the seat records on it whether that option rides a follow-up card, so #22779's pointer does not land on a closed card with no answer. Not a FAIL reason.

CI on the head, read after convergence: every check-run on ffb30285d is completed (34 distinct check names, latest run per name): 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 red. The seven required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate (all three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset, Flag docs affected by code changes, Dogfood Verify CLI, the three claim and closing-target guards, and the four Type Check legs. The PR head was still ffb30285d at that read.

Implemented-by: claude/issue-22769-versioned-signature
Reviewed-by: session_01CBAfsWMSfM3EToQGVStEcp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 08:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 08:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 7b0a9c9 Oct 11, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22769-versioned-signature branch October 11, 2026 09:01
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/l tests tooling

Projects

None yet

2 participants