Skip to content

fix(service-automation,metadata-protocol,runtime): withhold the remaining stored flow-credential positions at every depth, and answer a /meta list fault as itself (#20590) - #20615

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20590-credential-positions-closeout
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20590-credential-positions-closeout

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Part of #20590

Clause-②: no

What this changes

This PR closes the stored-credential positions that the first instance's projection (PR #20585) did not reach. Each position was measured first. Its pin was committed red on the unfixed code (99a75023) and then closed (1f17293b). The defect is stated abstractly here, per the security-family disclosure rule.

  • Position 1: a second credential kind in a flow node's config. The registered flow projection (service-automation/src/flow-credential-projection.ts) withheld only the start node's hook secret.
    • It now withholds every position in one table keyed by node kind, FLOW_NODE_CREDENTIAL_KEYS: the start node's secret and the http node's signingSecret.
    • It walks every ADR-0031 region (a loop body, parallel branches, try_catch try and catch) through FLOW_REGION_SLOTS_BY_TYPE. A credential-holding node nested at any depth is therefore covered.
    • Round trip: the rule is unchanged. The withheld form (the key absent) keeps the stored value, and an explicit value replaces it.
    • Removal door, with no spec change: the empty string.
      • HttpConfigSchema already accepts it, and it holds no secret.
      • The messaging outbox signs only with a non-empty secret.
      • The projection serves it as written, so it round-trips as "cleared". Absent is never read as "remove".
    • Enumeration pin: flow-credential-positions.test.ts.
      • It reads every declared node config contract: each builtin executor's descriptor configSchema, the schemaless builtins' spec Zod contracts, and the approval node's contract.
      • It requires the table to equal the credential-named keys it finds, and each to be withheld at the top level and in every region kind.
      • A newly declared credential key turns it red until the key is covered, or reviewed out with a reason. It is a test in the same package, not a gate.
  • Position 2: a list fallback that served stored bodies on a protocol fault. The runtime dispatcher's /meta list branch (runtime/src/domains/meta.ts) no longer swallows a throw from the protocol's list read. The throw is answered as itself (errorFromThrown). That is one option, per triage's call.
  • Position 3: identity versus kind. carryForwardRedactedValues (metadata-protocol/src/metadata-redaction.ts) now does two more things.
    • It re-runs the type's redactor over what it grafted, and drops any carried value whose new position the read would serve. This is the remedy the at-tier review named.
    • It walks an array element that has no id by the identified node beneath it on the same path. A parallel branch has no id. Position 1 needs this: once a secret inside a branch is withheld, the old "skip the path" would have deleted it silently on every round trip, including one that reorders the branches.

The dispatch's mechanism assumptions, measured

  • A1 held. Position 1 is REACHED at a member-level read.
  • A2 partly falsified: there is no "unknown type" error to discriminate on.
    • ObjectStackProtocolImplementation.getMetaItems is the one implementation in this repository. It answers a type it holds nothing for with an empty list, because it merges the metadata service's runtime-registered items itself.
    • What it throws is a store fault (503), a metadata app's marked refusal, a redactor failing closed, or a refused spelling (400). So every throw now propagates, which is what "a fault propagates, an unknown type still falls through" reduces to.
    • The metadata-service fallback stays for a protocol slot with no list verb.
    • Position 2 is REACHED only under a forced fault, and only on a dispatcher-routed host. A member read on the real HttpDispatcher with a forced protocol fault served a flow's hook secret and a datasource's password, 200.
    • On the composed boot above, the /meta list is RestServer's route. It has no fallback, and a forced fault there answered 503 with nothing served.
  • A3 held. Position 3 is REACHED at a member-level read after an authoring round trip.
    • On the same composed boot at c96beb27, an author's ordinary save kept a node's id and changed its kind. The member's next item and list reads then served the old hook secret on that node, and the row at rest held it there.
    • After this change the carried value is dropped. The node's config at rest is empty, and nothing is served.
  • A4: none, as specified. Details are under Acceptance notes. No in-repo composition serves /meta without the automation capability while sharing a store with one that loads it. The plugin-absent half does exist in the repository, measured below.

Tests

The pins below were all committed red first, at 99a75023.

  • service-automation, flow-credential-positions.test.ts (new): the enumeration, every position at each depth, exact paths, the cleared form, and a lookalike key on another kind.
  • metadata-protocol, protocol.metadata-redaction.test.ts:
    • relocation through the pure inverse and through the save door (followed by the served reads);
    • nested carry-forward, including reordered branches and twin branches;
    • the removal door: the empty string clears, absent keeps, and a value replaces;
    • the save-door round trip for nested secrets.
  • runtime:
    • meta-list-protocol-fault.test.ts (new): 503, 400 and an undeclared throw are each answered as themselves, with the fallback never called. It also preserves the empty-list answer and the no-list-verb host.
    • automation-flow-credential-projection.test.ts: the relocating PUT, then the member's read.
    • meta-list-read-gate-parity.test.ts: its double now reaches the fallback exits by answering no list instead of throwing.

Run at fee1d6b9b:

  • Package suites:
    • service-automation: 152 files, 1866 passed.
    • metadata-protocol: 2775 passed, 19 skipped.
    • runtime (--project local): 4197 passed, 1 skipped.
  • typecheck is green on all three. check:test-typecheck is green on service-automation and runtime. metadata-protocol's tsconfig includes its tests; --listFiles counts the edited test once.
  • Gates: dispatch-gates.mjs --commands derives 64 commands from this diff, and all 64 exit 0. --ran reconciles 64 derived, 64 run, 0 NOT-MEASURED and 0 UNRUN, every line carrying its exit code.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET, because 8 unrelated packages had no dist/. After building them it exited 0.
  • Lint: eslint --no-inline-config over the 8 changed .ts files reports 0 errors and 0 warnings.
    • The config lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} outside its never-linted directories.
    • It enables no type-aware rule (eslint.config.mjs, "never enables type-aware linting"), so the diff cannot move an untouched file's verdict.

Ablations

Each leg ran on the committed fix and went through scripts/ablation-replace.mjs: the anchor hit once, the blob changed, and the restore was proven as "blob == HEAD and git diff HEAD empty". The outer shell also carried a restore trap.

# Put back Pins that went red
A1 the http row of the table 8 of 15: the table no longer equals the declared set (expected [ 'start.secret' ] to deeply equal [ Array(2) ]), and each signingSecret placement (… not to contain 'credential-position-sentinel-20590')
A2 the region walk 9 of 15: every nested placement, for both kinds
A3 serving the cleared form as written 1 of 15
A4 the list branch swallowing the protocol throw 4 of 6: expected '{"success":true,"data":{"type":"flow"…' not to contain 'stored-hook-secret-20590', and the datasource password
A5 keeping every graft (no position check) 3 of 33 in metadata-protocol: expected [ 'inbound_hook', …(17) ] to not include 'stored-hook-secret-20552'. In runtime, which reads metadata-protocol from dist/, the PUT pin went red once the mutation was rebuilt and ablation-dist-preflight found the marker in 2 built files. On restore, the package was rebuilt, the marker was absent from all 24 built files, the tree was clean, and 8 of 8 passed.
A6 skipping an id-less element (no anchor) 3 of 33: expected undefined to be 'stored-signing-secret-20590'

A first run of the dist leg of A5 used a mutation that failed the package's DTS step (unused locals). Its JS bundles carried the mutation, but its preflight never ran. It was rerun with a mutation that type-checks, and the numbers above are from that rerun.

Deviations

  • Three test files outside the claim's named file surface, each a test of a door this PR changes:
    • runtime/src/domains/automation-flow-credential-projection.test.ts, the automation-plane half of position 3;
    • runtime/src/domains/meta-list-read-gate-parity.test.ts, whose double modelled "unknown type" as a throw;
    • the new runtime/src/domains/meta-list-protocol-fault.test.ts.
  • No change reaches a published package's exports. The new constants in flow-credential-projection.ts are not re-exported from @objectstack/service-automation's entry. carryForwardRedactedValues keeps its signature; its behaviour changes as described. Clause-②: no stands as claimed.

Acceptance notes

  • Position 4 measurement: none, as specified. Nothing in this repository composes a /meta-serving host without the automation capability over a store shared with one that loads it.
    • Searched: the requires of every example and dogfood fixture, where every stack declaring flows requires automation.
    • Searched: os serve's always-on slate, where automation is not on it and loads only by requires, and its presets.
    • Searched: the CLI commands that boot their own kernel. os verify boots in memory, and os meta goes through HTTP.
    • Searched: the one store-sharing seam, bootStack's databaseFile, where all 4 in-repo uses pass automation: true.
    • The plugin-absent half does exist. @objectstack/verify's bootStack loads the automation capability only when automation: true is passed (default false), whatever the stack's requires says. os verify boots it that way.
    • On bootStack(showcase) without the flag, the flow redactor was absent from the registry, /automation/* answered 501, and a member's /meta/flow read served an authored api flow's start-node secret.
    • Its store is private (in memory, seeded from the app's own source), so no secret live elsewhere is served there. Recorded for the seat, which owns the position-4 route.
  • A second open-map position, same family, not covered here.
    • HttpConfigSchema.headers is an open string map, and so is connectorConfig.input. A static credential typed into one is served with the definition, because it is marked by a header or parameter name, not by a declared key.
    • The enumeration pin covers declared keys only, and no shipped example authors one.
    • Named in the report for the seat. It is not filed here, per the family's fold rule.
  • Remaining fall-throughs on the dispatcher list. A protocol slot with no list verb, or a protocol answering no list, still reaches the metadata service's list without per-type redaction. The one in-repo protocol always answers a list, so this is dormant.
  • Observations, not filed (no credential served):
  • Boundary: a plugin-registered node kind that declares a credential key is outside the table. The enumeration reads builtin and spec-declared contracts.

Patch round 1 (the seat's append; the dev writes a body only once)

  • R1, the at-tier record 5886643746: a node moved across regions no longer loses its credential.
    • When the stored path to the credential's container does not resolve in the incoming body, carryForwardRedactedValues finds the owning element by its id across the whole incoming body. It uses that element only on exactly one match, then lets the existing position check decide where the value lands.
    • The pins were committed red on fee1d6b9 as 0661a9a0, with 3 failed. The fix and the changeset sentence are 5116194e.
    • The pins: a node moved out of a loop body, and a node moved into a parallel branch, each kept, both directly and through the save door. A node moved and changed in kind is dropped. An id duplicated across regions grafts nothing.
    • The ablation removed the by-id fallback. Exactly the 3 keep-pins went red, and the restore was proven.
    • The earlier test titled as a cross-region move is retitled to what it asserts.
    • A datasource path holds no identified element, so it is unaffected.
  • Also in this round: two stored paths that land on one incoming position carry neither, instead of one silently overwriting the other. With a flow's single id space this cannot be reached, and it errs on the side of not carrying.
  • R2 (measured only): the inline http arm, and the durable arm's no-outbox fallback, send the request unsigned. Filed as service-automation: a flow http node that sets signingSecret sends its request unsigned on the inline arm (and on the durable arm's no-outbox fallback), while the published contract promises X-Objectstack-Signature #20628. Not changed here.
  • At 5116194e: metadata-protocol passed 2780, with 19 skipped. runtime's pins for these doors passed 14. typecheck exit 0. dispatch-gates --commands derived 64 commands, all 64 exit 0, and --ran reconciles 64 / 64 / 0 / 0.

Patch round 2 (the seat's append)

  • N1, the at-tier record 5888558573: the relocation's match set is scoped to where the owner stood.
    • It counts only elements of arrays held under the same key as the owner's own array in the stored path. That key is read from the stored hops and never named in code. For a flow it is nodes, at the top level or in any region.
    • An edge, or a config value that carries the same id, no longer blocks the relocation. Uniqueness is still required within the scoped set.
    • An owner whose array sits directly inside another array has no key and is not relocated. No registered redactor produces such a path.
  • The pins were committed red on 5116194e as 2b7e04d3: the edge-twin case, directly and through the save door, 2 failed. The fix is 60a5f765.
  • Ablation: putting back the whole-body match turned exactly the 2 edge-twin pins red. The restore was proven.
  • Changeset: the "Moving a node" sentence now states the condition the code enforces.
  • At 60a5f765: metadata-protocol passed 2783, with 19 skipped. runtime's pins for these doors passed 14. typecheck exit 0. After a full build, dispatch-gates --commands derived 64 commands, all 64 exit 0, and --ran reconciles 64 / 64 / 0 / 0.

Generated by Claude Code

…r the remaining flow credential positions, red on the unfixed code

Three measurements, committed before any fix so each one is shown red first:

- service-automation: an enumeration pin reads every declared node config
  contract and asks the registered flow projection to withhold each
  credential-named key at every depth a node can sit (top level, loop body,
  parallel branch, try and catch regions).
- runtime: the dispatcher's /meta list read, for a member-level caller, when
  the protocol's list read faults.
- metadata-protocol and runtime: a round trip that keeps a node's id and
  changes its kind, read back afterwards.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…w credential position at every depth, keep a carried value off served positions, and answer a protocol list fault as itself

- service-automation: the flow projection withholds an http node's
  config.signingSecret beside the start node's config.secret, through every
  loop, parallel and try/catch region. The positions are one table by node
  kind; the empty string is the explicit clearing value and is served as
  written, so an optional secret can be removed across a round trip whose
  absent key means "unchanged".
- metadata-protocol: the write-path inverse re-runs the type's redactor over
  what it grafted and drops a carried value whose new position the read would
  serve. An array element with no id (a parallel branch) is walked by the
  identified node beneath it on the same path.
- runtime: the /meta list read no longer swallows a protocol throw and serves
  the metadata service's unredacted list; the throw is answered as itself.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…time for the remaining flow credential positions

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/service-automation, touching 18 documentable anchor(s).

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

  • content/docs/permissions/system-context.mdx (via handleMetadataRequest (symbol, a top-level function))
What this run could not see
  • 1 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 — 32 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 b80ab579d8bdd7499805612104ce95d931b0f03e → packageMentionDocs.

Which tree this was computed on

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

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

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

…ed on fee1d6b where the moved node's credential is dropped at save

(a) an http node moved out of a loop body to the top level, (b) one moved
from the top level into a parallel branch, and (a) again through the save
door followed by the served reads: each loses the stored signing secret
today. (c) a node moved into a region and changed in kind, and (d) an id
duplicated across two regions, are the guards that must keep holding.
Retitles the old region test to what it asserts: the node stays in place.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…edential on a round trip

When the stored path to a redacted key no longer resolves in the incoming
body, because the element that owns it moved (out of a loop body, into a
parallel branch) keeping its id, the carry-forward now finds that element by
its id across the whole incoming body, using it only when exactly one object
carries that id. The existing position check still decides whether the value
lands, so a node moved and changed in kind still does not carry it. Two
stored paths landing on one incoming position carry neither. A path with no
identified element (every datasource path) is unaffected.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…shares, red on 5116194 where the edge blocks the relocation

(e) a moved node plus an edge sharing its id, as a pure inverse and through
the save door (a schema-valid flow): the moved node's signing secret is
dropped today, because the by-id lookup counts the edge as a second match.
(f) two nodes sharing the moved node's id in different regions is the guard
that must keep grafting nothing.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…at stand where it stood, not every object carrying its id

The by-id lookup counted every plain object in the body whose id equals the
moved node's, edges and nested config values included. A flow keeps node and
edge ids in separate spaces, so an edge sharing a moved node's id is valid,
counted as a second match, blocked the relocation and dropped the stored
credential at save. The match set is now the elements of arrays held under
the same key as the owner's array in the stored path (read from the stored
hops, not named), and must still be unique across the body. The changeset's
"Moving a node" sentence now states that condition.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 11:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 3f45b6c Sep 29, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20590-credential-positions-closeout branch September 29, 2026 11:41
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…data/analytics.zod.ts to the commits that decided them (stage 7) (objectstack-ai#20616)

Part of objectstack-ai#20234
Clause-②: no

Stage 7 of the staged sweep: `packages/spec/src/stack.zod.ts` and
`packages/spec/src/data/analytics.zod.ts`, both freed by landings (PR
objectstack-ai#20579 and PR objectstack-ai#20458). Its claim is `5885635758`. Every comment or
docblock line in those two files that cited a tracker number answering
404 now cites the commit on `main` that decided its rule, in ruling
C+D's form C, and says in its own words what was decided. Comments only:
12 lines out, 12 in, across 2 files. No code token, string literal or
`describe()` text moves. No dead site stays: none of the 12 is read by
literal.

The census is the gate's own `node scripts/check-issue-citations.mjs
--census --json`, filtered to the two paths. Before: base `0f6dcac5e9`,
board enumerated (185 pages, frontier objectstack-ai#20611). After: head `cc0580d404`,
board enumerated (185 pages, frontier objectstack-ai#20615).

## Measurement

| file (under `packages/spec/src/`) | dead before | after | numbers,
then anchor |
|---|---:|---:|---|
| `stack.zod.ts` | 9 | 0 | objectstack-ai#10485 ×2 (`:415`, `:1023`) to `35ad101bc`;
objectstack-ai#6238 (`:633`) to `c8d6f6e08`; objectstack-ai#14192 (`:1233`) to `4d0d9445a`; objectstack-ai#14686
×2 (`:3037`, `:3194`) to `279431e7a`; objectstack-ai#14662 ×3 (`:4510`, `:5070`,
`:5293`) to `35dffeace` |
| `data/analytics.zod.ts` | 3 | 0 | objectstack-ai#10194 ×3 (`:404`, `:407`, `:485`)
to `2306a765c` |
| **2 files** | **12** | **0** | 6 numbers removed, 6 distinct shas |

Per-file counts at base equal the claim's (9 and 3, from stage 6's
census). A second instrument agrees site for site: every `#N` in the two
files, classified by the TypeScript parser, and each of the 84 distinct
numbers of 100 or more probed by REST `issues/N` without following
redirects (the other 3 are the ordinals `Prime Directive objectstack-ai#12`, `batch
objectstack-ai#23`, `batch objectstack-ai#57`).
- Base: 244 sites, all in comments (0 strings, 0 code). 78 numbers
answer 200 and 6 answer 404: the same 6 numbers and the same 12 sites as
the gate.
- Its string-class positive control found 11 string sites in
`kernel/manifest-unknown-keys.test.ts` and
`packages/cli/src/utils/lower-callables.test.ts`.
- Head: 232 sites, 78 numbers, all 78 answer 200 (the same 78), none
answers 404.
- Lit controls objectstack-ai#16862, objectstack-ai#16847 and objectstack-ai#17698 answered 200 at every
checkpoint (3 at base, 3 at head); dead controls objectstack-ai#16714, objectstack-ai#16715 and
objectstack-ai#16697 answered 404 at every checkpoint.

## Why each anchor decides its line

Each sha resolves uniquely, is an ancestor of `origin/main` (and of the
base), and has one parent. No file under `docs/adr/**`,
`docs/NORTH-STAR.md` or `scripts/adr-anchors/` names any of the six
numbers or records these rules, so each takes the commit rung, as stages
1–6 did.

- **objectstack-ai#10485 to `35ad101bc`** (`:415`, `:1023`): retires the `themes`
carrier key and `ThemeSchema` under ADR-0049. Its message records the
ruling, "Ruled B (退役授权面, 2026-08-21)", and its own `stack.zod.ts` diff
wrote both lines. `:415` keeps ADR-0049 and the ruling in its words; the
D3 entry `stack-themes-carrier-retired` it names on `:423` is unchanged.
This is the anchor stages 1, 5 and 6 used for the same retirement.
- **objectstack-ai#6238 to `c8d6f6e08`** (`:633`): widens the array member of
`functions` so its `handler` also takes the lowered string ref, which is
the fix for `objectstack build` refusing its own array output. Its
message names objectstack-ai#6238, and its own diff wrote the line. objectstack-ai#4343 and objectstack-ai#4976 on
the same line stay (both 200).
- **objectstack-ai#14192 to `4d0d9445a`** (`:1233`): turns `ManifestSchema` and its
nested blocks into `strictObject` and flips the assembled-body strip pin
to a refusal pin; each of its sub-commits names objectstack-ai#14192. The line itself
was written later by `c78c9180de`, whose own message says "objectstack-ai#14192 closed
ManifestSchema with strictObject", so the commit that closed it is the
anchor.
- **objectstack-ai#14686 to `279431e7a`** (`:3037`, `:3194`): "defineStack refuses two
actions that resolve to one scope-qualified runtime key". Its subject
names objectstack-ai#14686, and its diff adds `collectDuplicateActionKeyErrors` and
the changeset for that refusal. Both lines were written later by
`773a99960a` (PR objectstack-ai#15022), whose message describes the same "same-key
rule, which runs before the merge".
- **objectstack-ai#14662 to `35dffeace`** (`:4510`, `:5070`, `:5293`): "composeStacks
refuses two stacks whose actions resolve to one scope-qualified runtime
key". It checks the composed set with the rule `defineStack` applies
within one stack, with no `actionConflict` option (maintainer ruling
2026-09-03). Its message does not name objectstack-ai#14662; its own `stack.zod.ts`
diff wrote all three `(objectstack-ai#14662)` lines.
- **objectstack-ai#10194 to `2306a765c`** (`analytics.zod.ts:404`, `:407`, `:485`):
binds `analytics_cube` (and `theme`) in `UNREGISTERED_KIND_SCHEMAS`, so
`PUT /meta/analytics_cube/:name` parses through `CubeSchema`, and gives
`CubeSchema` the `...MetadataProtectionFields` spread. Its message names
objectstack-ai#10194, and its own diff wrote all three lines. The `[objectstack-ai#10194]` markers
become `[commit 2306a76]`, the spelling stages 1 and 5 already use in
`kernel/metadata-type-schemas.ts`.

## Mechanical proof

- **Token guard** (my `tokcmp.mjs`: TypeScript 6.0.3 leaf tokens, JSDoc
kinds excluded, controls mutate the head text in memory only). Base
`0f6dcac5e9` against the head, 2 files, 17,249 base tokens:
  - Real run: 0 files with a token change (exit 0).
  - Comment-insertion control (`data/analytics.zod.ts`): 0 (exit 0).
- Code-insertion positive control (`stack.zod.ts`, a declaration
appended): DIFFER at token 15388 (exit 1).
- String positive control (the first `StringLiteral` the parser locates
in each file): DIFFER at token 5 (exit 1), once per file.
- `describe()` positive control (the first `.describe()` string argument
the parser locates: `stack.zod.ts:133`, `analytics.zod.ts:244`): DIFFER
at tokens 507 and 442 (exit 1).
- **Line balance**: `stack.zod.ts` +9/−9, `data/analytics.zod.ts` +3/−3;
line counts equal at base and head (5344 and 853).
- **Tracker numbers**: added-not-removed is empty in both files, and no
`PR #N` is on an added line. Net-removed: 12 sites, 6 numbers. The only
numbers on added lines are objectstack-ai#4343 and objectstack-ai#4976, which stay on `:633`.
- **Shas**: 6 distinct on added lines, 0 on removed lines.
  - `rev-parse --disambiguate` answers 1 object for each.
- `merge-base --is-ancestor` exits 0 for each, against `origin/main`
`7510663c87` and against the base; each is single-parent; the repository
is not shallow.
- **Literal readers**: all 26 string, template and regex literals in the
repository that carry one of the six numbers (42 code files) were
matched against the two files' base text: 0 occur there. Each removed
line was also cut into 4-word windows (96) and searched across the tree:
the 9 hits inside string literals are other files' own test titles
sharing a phrase ("the ADR-0010 protection envelope", "an assembled body
is"), and none reads either file. The source-text readers of the two
files read code, not these comments:
`compose-stacks-refusal-envelopes.test.ts` counts `throw new Error(`,
and `check-stack-collection-maps.mjs` and
`check-skill-top-level-keys.mjs` read the declared collections and keys.

## Tests and gates (at head `cc0580d404`)

- `pnpm exec turbo run build --concurrency=2 --filter=./packages/*
--filter=./packages/*/*` under `os-verify-lock`: Tasks 71 successful, 71
total, VERDICT command-exit 0.
- `pnpm --filter @objectstack/spec check:generated` under the lock: all
15 generated artifacts up to date, `check:docs` over
`content/docs/references/**` included; VERDICT command-exit 0. No
reference page projects any of the 12 lines, so none is regenerated.
- `vitest run --maxWorkers=2` under the lock over the two files' own
suites (`src/stack*`, `src/compose-stacks*`, `src/define-stack*`,
`src/assembled-package-body`, `src/data/analytics*`, `src/data/cube*`):
Test Files 35 passed (35), Tests 976 passed (976).
- The 37 spec suites that read source text across `src/`, or carry one
of these numbers, under the lock: Test Files 37 passed (37), Tests 759
passed (759).
-
`scripts/{category-title,dist-freshness,dist-freshness-adoption,file-description,strictness-ledger,strictness-ledger-doc,root-index,skill-map-guards,export-origins,split-entries,root-entry-type-nameability.pin}`,
`scripts/liveness/{evidence,tombstoned-row-status}`;
- `src/type-alias-convention.pin`, `src/eager-entry-import`,
`src/api/{api-entry-graph.pin,auth,export-job-family-retirement}`,
`src/ai/tool-confirmation-prescription-tense.pin`,
`src/data/{currency-mode-family-closure.pin,external-lookup-retirement}`,
`src/identity/position-delegatable-enforcer.pin`,
`src/integration/{connector-connection-timeout-retirement,connector-resilience-keys-retirement}`,
`src/security/rls-tags-retirement`,
`src/shared/{alias-integrity,retired-key-migrate-sentence}`,
`src/system/{compliance-families-retirement,constants/platform-object-names,email-template-floor-locale-parity.pin,message-queue-retirement}`,
`src/ui/{action-requires-confirmation-docblock.pin,i18n,interaction-config-retirement,strictness-batch14}`,
`src/kernel/{manifest-unknown-keys,metadata-type-schemas}`.
- Left to CI:
`scripts/{build-schemas-check-mode,def-key-collisions,openapi-self-consistency}`
(each rebuilds artifacts in a temp tree) and
`scripts/{check-generated-ledger,check-generated-fix-rebuild.pin}` (read
the ledger and `dist`). None reads comment text.
- `pnpm --filter @objectstack/spec typecheck` under the lock: exit 0;
`check:test-typecheck` OK (53 files / 251 errors / 138 pinned signatures
held).
- Lint, a proven narrowing: `eslint --no-inline-config --format json`
over the 2 files gives 2 files, 0 errors, 0 warnings.
  - `isPathIgnored` is false for both, read through eslint's API.
- `eslint.config.mjs:327-328` says type-aware linting is never enabled,
so a comment edit cannot move an untouched file's verdict.
  - The repo-wide `pnpm lint` is CI's.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 79 families derived and run, every one exit 0. `--ran`
reads "79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN". Among them:
- `pnpm check:issue-citations` (self-test, 114 cases in 8 batteries) and
the live diff-scoped `node scripts/check-issue-citations.mjs`: it judged
the 2 citations on added lines, objectstack-ai#4343 and objectstack-ai#4976, and both are live
issues.
- `pnpm check:doc-authoring`: 16,804 customer-facing strings across
1,179 spec sources clean; the sibling baseline holds.
- `pnpm check:stack-collection-maps`: 8 enumerations reconciled against
31 declared collections.
- Changeset: `patch` for `@objectstack/spec`. Both files are
`src/**/*.zod.ts`, which `files[]` ships verbatim, and the rewritten
docblocks reach `dist`: "posture: commit 4d0d944 closed" and "[commit
2306a76] This docblock used to say" are each in 2 `.d.ts`, their old
spellings in 0. Positive control: the unchanged neighbouring sentence
"BY INHERITANCE — an undeclared key on one is REFUSED" is in the same 2
`.d.ts`.
- Merge probe: a no-driver `merge-tree` of the head onto `origin/main`
`7510663c87`, from a bare shared clone, exits 0. The 3 commits `main`
gained since the base touch neither file nor the citation or derivation
scripts, and a re-derivation prints the same 79 commands. No merge was
made.
- No ablation or reverse verification: the change is comment-only, so
there is no behaviour to invert.

## Hypotheses (measured first)

1. **Holds.** 12 dead sites at the tip, 9 in `stack.zod.ts` and 3 in
`data/analytics.zod.ts`, equal per file to stage 6's census.
2. **Holds.** Read at 2026-09-29T07:36Z and again at 08:16Z, after the
last push and before this PR was opened: all open PRs' full file lists
(9 PRs, 166 files at the second read) and the newest `Claim:` on all 11
`pm:dispatched` cards. None names either file, except this card's own
claim.
3. **Holds, with nothing to keep.** All 12 sites are comments. No test
string, exported string or `describe()` text carries one, and no test or
script reads any of them by literal.
4. **Holds.** No generated reference page projects these lines;
`check:docs` is green with no regeneration.

## Deviations

- None to the file surface: the 12 claimed lines and one changeset, no
generated page needed.
- Commit trailers follow AGENTS.md's model-free pair (`Claude-Session`
plus `Co-authored-by: Claude`); the pre-push trailer check passed on
every push.

## Acceptance notes

**What stays for later stages.** The gate's census at this PR's head
(base `0f6dcac5e9` plus this PR) reads **248** dead sites (29 numbers)
in `packages/spec/src`. The only `packages/spec/src` change `main` has
made since the base (objectstack-ai#20610's migrations entry and registry) adds four
live numbers and removes none, so 248 also stands at the tip
`7510663c87` plus this PR:
- `migrations/` **233**: objectstack-ai#20233 edits the same entry files (PR objectstack-ai#20607
holds `migrations/registry.ts`).
- `conversions/registry.ts` **12**: PRs objectstack-ai#20570 and objectstack-ai#20587 hold it.
- `integration/connector.zod.ts` **1**: PR objectstack-ai#20587 (objectstack-ai#20287).
- `data/api-derivation.ts:163` (objectstack-ai#6259) and
`identity/identity.zod.ts:230` (objectstack-ai#8715), **1** each: kept because tests
read them by literal, so removing them is form D.

**Outside the gate's census: test files.** The gate defers `*.test.ts`.
The same six dead numbers still stand at 15 comment sites and 10
test-title strings in `packages/spec/src` test files:
- `data/analytics-strictness-batchd.test.ts:96` (comment, objectstack-ai#10194) and
its title `:93`. This file is in the `analytics*` set stages 3 and 4
excluded while PR objectstack-ai#20458 held it;
`analytics-date-range-two-bound-window.test.ts` and
`cube-member-inner-name-retirement.test.ts` were in that set too and are
not re-measured here.
- The package root: `compose-stacks-action-echo.test.ts:20`, `:34`,
`:200` (objectstack-ai#14686) and titles `:176`, `:224`;
`compose-stacks-action-key-collision.test.ts:3` (objectstack-ai#14662);
`stack-top-level-strict.test.ts:103` (objectstack-ai#10485) and title `:128`;
`type-alias-convention.pin.test.ts:257`, `:1572`, `:1937` (objectstack-ai#10485).
- `shared/`: `metadata-collection.test.ts:250`,
`metadata-url-spelling.test.ts:51`, `:72`, `:168` (objectstack-ai#10485), `:257`
(objectstack-ai#10194), title `:254`. `automation/sync-retirement.test.ts:207`
(objectstack-ai#10485).
- `kernel/`: `manifest-unknown-keys.test.ts`, four titles (objectstack-ai#14192);
`metadata-type-schemas.test.ts:422`, a title (objectstack-ai#10194).
- Stage 6 took the package root, `shared/` and `automation/` through the
gate's census, which never lists a test file, so test-file comment lines
there may carry other dead numbers as well. That wider population is not
measured here.

**Outside `packages/spec/src`.** The same six numbers stand at 44 more
sites
(`packages/{metadata-protocol,objectql,rest,runtime,cli,core,metadata,qa}`,
`examples/`, `scripts/`, `packages/spec/scripts/`), and at 19 sites in
`migrations/` (the objectstack-ai#20233 area).

**Rung.** The objectstack-ai#10485 retirement also has the ADR-0087 D3 entry
`stack-themes-carrier-retired`, which `:423` already names. This PR
takes the commit rung, as stages 1–6 did.

**Wording, each true of its commit.** `:3037` and `:3194` now read
"commit 279431e's same-key refusal": the refusal that commit added, in
lines `773a99960a` wrote. `:1233` reads "commit 4d0d944 closed
`ManifestSchema`", in a line `c78c9180de` wrote.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…me/src to the commits that decided them (objectstack-ai#20624)

Part of objectstack-ai#20594
Clause-②: no

## What changed

This is stage 1 of the `domain:cli` lane of the dead-citation sweep:
`packages/runtime/src/**`, the lane's largest package. Every comment or
docblock site in scope that cited a tracker number answering 404 now
cites, in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), the commit
in this repository's history that decided what the line describes, and
says in its own words what that commit decided. PR objectstack-ai#20533 is the method
and PR objectstack-ai#20609 the closest sibling. Later stages cover `rest`, `cli`,
`types` and the rest of the lane, so this PR says `Part of` and the card
stays open.

That is **513 comment sites on 508 lines in 118 files, covering 96
numbers**: 194 of the census's 217 sites, and 319 more in test comments,
which the census defers. Three more sites carried a slash-joined dead
number the citation grammar does not read (`objectstack-ai#10629/objectstack-ai#10630`,
`objectstack-ai#5811/objectstack-ai#12281`, `objectstack-ai#8421/objectstack-ai#12194`), and they are rewritten too. Each
rewritten line cites one of **95 distinct commits**.

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of these numbers. ADR-0126 and ADR-0131
name objectstack-ai#10243 only as the incident, ADR-0126 names objectstack-ai#11513 only for the
flow-clone half, and ADR-0112 names objectstack-ai#12281 only as another card. So
every anchor is a commit. The anchors the landed stages already gave the
same numbers are reused (24 numbers, for example `f19475c0a` for objectstack-ai#14143,
`e2798fab7` for objectstack-ai#6345 and `79c46da90` for objectstack-ai#9934), so each number carries
one anchor across the tree.

Only comments changed. Every touched file keeps its line count (508
lines out, 508 in, over 118 files), so no line citation into these files
moves. Seven of the 508 lines held no census site. Five are the other
half of a sentence that had to change:
`action-governance-scope-divergence.test.ts:6` (「the card names」 to
「that diverged」, because line 4 no longer names the card),
`action-record-load-denied.test.ts:560`,
`dispatcher-5xx-demoted-code-withhold.test.ts:45` (「that card's change」
to 「that commit's change」),
`hook-input-writeback-readonly-provenance.integration.test.ts:380`
(「that card」 to 「that commit」) and
`standalone-stack-seeder-declaration-copy.test.ts:88` (a trailing 「PR」
whose number wrapped onto line 89). Two carry only a slash-joined
number: `dispatcher-plugin.ts:688` and
`meta-compound-arity-mint-door.test.ts:4`. No code token moves (see the
guard below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. No PR number stands on an added
line, and none of the 95 shas is on a removed line.

Twenty-eight dead comment sites are left on purpose:
- **20 in `domains/meta.ts`.** PR objectstack-ai#20615 (objectstack-ai#20590's) opened at
2026-09-29T08:12:40Z, after this stage's claim and first read, and edits
that file. So the file went back to its base blob (`b4ddb362cc`) in
`a5cdfd8a46`, as PR objectstack-ai#20612 did with `authoring-rules.ts`. The anchors
are verified and listed below for the follow-up.
- **8 with no deciding commit, or with a literal reader.** See "The
sites left" below.

One more file: a `patch` changeset for `@objectstack/runtime`, because
the rewritten docblocks ship (see Changeset below).

## Census: `packages/runtime`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run with the fleet token. Its
surface is comment prose in `packages/**/src/**/*.ts` with string
literals blanked, and it defers `*.test.ts`. The count is its
`allocated-but-absent` findings under `packages/runtime/`. Both runs
enumerated the whole board (185 pages), so neither read a truncated
board.

| reading | tree | board | whole-repo `allocated-but-absent` | runtime
sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `eb4b17c346`, run 2026-09-29T07:55:51Z to 08:05:47Z |
enumerated, 185 pages, frontier objectstack-ai#20614, 18,441 numbers | 2,397 | **217**
| 216 | 29 | 59 |
| after | head `a5cdfd8a46`, run 09:08:23Z to 09:14:11Z | enumerated,
185 pages, frontier objectstack-ai#20623, 18,450 numbers | 2,027 | **23** | 23 | 4 |
12 |

The before count equals the card's 217 at `f11b5f20a2`. The 23 left are
the 20 held `domains/meta.ts` sites and 3 deliberate ones
(`api-exposure.ts:108`, `domains/mcp.ts:360`, `route-ledger.ts:300`).
The whole-repo drop is 370: this diff's 194, plus the 97 and 79 of PR
objectstack-ai#20609 and PR objectstack-ai#20612, which landed on `main` in between and came in with
the merge.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `classifyCitation` over
every `.ts` file under `packages/runtime/src` (373 files), against a
board probed by REST for every number cited there. The lit controls
objectstack-ai#16862, objectstack-ai#16847 and objectstack-ai#17698 answered 200 and the dead controls objectstack-ai#16714,
objectstack-ai#16715 and objectstack-ai#16697 answered 404 in both runs.

| reading | tree | citations | dead | src comment | test comment | src
string | test string |
|---|---|---|---|---|---|---|---|
| before, 08:17:55Z | `eb4b17c346` | 5,364 | **641** | 217 | 324 | 5 |
95 |
| after, 09:24:24Z | `a5cdfd8a46` | 4,851 | **128** | 23 | 5 | 5 | 95 |

Its src-comment column equals the census's 217 and 23, which is the
control on the second instrument. The 4,499 resolving citations, the 195
that resolve as pull requests and the 29 cross-repo ones are the same in
both readings. The drop is 513, exactly this diff's grammar-read sites.

## Per-number table

Sites and files are the dead comment sites in scope at the base, tests
included. `held` is `domains/meta.ts` (see above) and `left` is a site
with no deciding commit or with a literal reader. `strings kept` counts
string-literal sites, which are tokens and stay as they were. Every
anchor was read in its message or its diff, not only in its subject: it
is the commit that made the change the line describes, and its own
message or diff names the number it replaces.

| number | comment sites / files | rewritten | held | left | strings
kept | anchor |
|---|---|---|---|---|---|---|
| `objectstack-ai#6065` | 1/1 | 1 | 0 | 0 | 0 | `026101660` |
| `objectstack-ai#6123` | 1/1 | 1 | 0 | 0 | 0 | `59d1933f9` |
| `objectstack-ai#6206` | 5/2 | 5 | 0 | 0 | 0 | `8e13ca876` |
| `objectstack-ai#6216` | 2/1 | 2 | 0 | 0 | 1 | `f586f1a89` |
| `objectstack-ai#6220` | 1/1 | 1 | 0 | 0 | 0 | `83df2fd73` |
| `objectstack-ai#6238` | 2/2 | 2 | 0 | 0 | 2 | `c8d6f6e08` |
| `objectstack-ai#6259` | 4/2 | 3 | 0 | 1 | 1 | `6968885ef` |
| `objectstack-ai#6265` | 12/2 | 12 | 0 | 0 | 4 | `cfb549db8` |
| `objectstack-ai#6268` | 9/3 | 9 | 0 | 0 | 0 | `68f5eccb1` |
| `objectstack-ai#6287` | 1/1 | 1 | 0 | 0 | 0 | `84c86fb45` |
| `objectstack-ai#6307` | 1/1 | 1 | 0 | 0 | 0 | `293476148` |
| `objectstack-ai#6316` | 6/3 | 6 | 0 | 0 | 0 | `448ac9565` |
| `objectstack-ai#6345` | 10/3 | 10 | 0 | 0 | 0 | `e2798fab7` |
| `objectstack-ai#6361` | 4/2 | 4 | 0 | 0 | 6 | `90bbf2510` |
| `objectstack-ai#6363` | 6/2 | 6 | 0 | 0 | 2 | `17d095413` |
| `objectstack-ai#6483` | 3/2 | 3 | 0 | 0 | 0 | `ee58392e1` |
| `objectstack-ai#8722` | 1/1 | 0 | 0 | 1 | 0 | — |
| `objectstack-ai#8724` | 1/1 | 1 | 0 | 0 | 0 | `ff4ba6a06` |
| `objectstack-ai#8726` | 8/4 | 7 | 1 | 0 | 1 | `e783e163d` |
| `objectstack-ai#8796` | 13/3 | 13 | 0 | 0 | 4 | `a4331227b` |
| `objectstack-ai#8848` | 3/2 | 1 | 2 | 0 | 1 | `4fc4a3c0b` |
| `objectstack-ai#8919` | 1/1 | 0 | 1 | 0 | 0 | `b5378550e` (held file) |
| `objectstack-ai#9934` | 17/7 | 17 | 0 | 0 | 4 | `79c46da90` |
| `objectstack-ai#9967` | 1/1 | 1 | 0 | 0 | 0 | `8f266f1cd` |
| `objectstack-ai#10179` | 1/1 | 0 | 0 | 1 | 2 | — |
| `objectstack-ai#10243` | 40/13 | 40 | 0 | 0 | 9 | `266436a7f`, `02b41232d` |
| `objectstack-ai#10293` | 3/3 | 3 | 0 | 0 | 0 | `92a69d813` |
| `objectstack-ai#10338` | 1/1 | 1 | 0 | 0 | 0 | `d2619fd0c` |
| `objectstack-ai#10340` | 3/2 | 2 | 1 | 0 | 1 | `26f3588fb` |
| `objectstack-ai#10380` | 12/2 | 12 | 0 | 0 | 0 | `dd8172ee2` |
| `objectstack-ai#10485` | 3/3 | 3 | 0 | 0 | 0 | `35ad101bc` |
| `objectstack-ai#10503` | 8/2 | 3 | 5 | 0 | 1 | `67ceb9aef` |
| `objectstack-ai#10537` | 2/1 | 2 | 0 | 0 | 0 | `e634ecf6a` |
| `objectstack-ai#10554` | 1/1 | 1 | 0 | 0 | 0 | `6abc4df03` |
| `objectstack-ai#10629` | 75/23 | 75 | 0 | 0 | 0 | `13a6cb4ad` |
| `objectstack-ai#10630` | 4/1 | 4 | 0 | 0 | 0 | `dd8172ee2` |
| `objectstack-ai#10789` | 2/1 | 2 | 0 | 0 | 1 | `38bc74ed1` |
| `objectstack-ai#10886` | 1/1 | 1 | 0 | 0 | 1 | `809e61221` |
| `objectstack-ai#10888` | 3/3 | 2 | 1 | 0 | 1 | `d806081dd` |
| `objectstack-ai#10961` | 5/3 | 5 | 0 | 0 | 3 | `222d06fc1` |
| `objectstack-ai#10965` | 2/1 | 2 | 0 | 0 | 1 | `ab47f6974` |
| `objectstack-ai#10978` | 1/1 | 1 | 0 | 0 | 0 | `4c9780c7a` |
| `objectstack-ai#10983` | 3/2 | 3 | 0 | 0 | 0 | `6a4e929f5` |
| `objectstack-ai#11006` | 4/4 | 3 | 1 | 0 | 0 | `cccbe51bf` |
| `objectstack-ai#11015` | 3/1 | 3 | 0 | 0 | 0 | `82cb6e849` |
| `objectstack-ai#11166` | 8/3 | 8 | 0 | 0 | 4 | `735f5c709` |
| `objectstack-ai#11333` | 1/1 | 1 | 0 | 0 | 0 | `ea4d16420` |
| `objectstack-ai#11504` | 3/2 | 3 | 0 | 0 | 0 | `f90e82024` |
| `objectstack-ai#11513` | 2/2 | 2 | 0 | 0 | 0 | `e170b0ae5` |
| `objectstack-ai#11703` | 8/3 | 8 | 0 | 0 | 1 | `5cb62d88b` |
| `objectstack-ai#12010` | 1/1 | 1 | 0 | 0 | 0 | `77b91bdb4` |
| `objectstack-ai#12176` | 5/5 | 5 | 0 | 0 | 0 | `7986d973f` |
| `objectstack-ai#12194` | 11/4 | 8 | 3 | 0 | 0 | `311433f6b` |
| `objectstack-ai#12195` | 9/4 | 4 | 5 | 0 | 10 | `7986d973f` |
| `objectstack-ai#12281` | 20/5 | 20 | 0 | 0 | 5 | `0783d7b80` |
| `objectstack-ai#12943` | 7/3 | 7 | 0 | 0 | 0 | `090f2302e` |
| `objectstack-ai#13037` | 8/2 | 8 | 0 | 0 | 5 | `e7dfb1d69` |
| `objectstack-ai#13233` | 5/1 | 5 | 0 | 0 | 0 | `3800e4293` |
| `objectstack-ai#13241` | 5/4 | 5 | 0 | 0 | 1 | `a21d2a9cf` |
| `objectstack-ai#13273` | 11/3 | 11 | 0 | 0 | 0 | `3a86a65e7` |
| `objectstack-ai#13279` | 3/2 | 3 | 0 | 0 | 0 | `6a180e42d` |
| `objectstack-ai#13325` | 3/1 | 3 | 0 | 0 | 0 | `2e0b7b18f` |
| `objectstack-ai#13644` | 5/4 | 5 | 0 | 0 | 1 | `34ce8e7db` |
| `objectstack-ai#13657` | 13/1 | 13 | 0 | 0 | 1 | `b003cf2e8` |
| `objectstack-ai#14143` | 26/8 | 26 | 0 | 0 | 4 | `f19475c0a` |
| `objectstack-ai#14390` | 1/1 | 1 | 0 | 0 | 0 | `9d7f7259f` |
| `objectstack-ai#14398` | 3/1 | 3 | 0 | 0 | 0 | `317132495` |
| `objectstack-ai#14403` | 6/1 | 6 | 0 | 0 | 0 | `93d2d679b` |
| `objectstack-ai#14421` | 2/1 | 2 | 0 | 0 | 0 | `bd8795ea1` |
| `objectstack-ai#14422` | 4/2 | 4 | 0 | 0 | 4 | `dc7c226b9` |
| `objectstack-ai#14423` | 3/1 | 3 | 0 | 0 | 1 | `a56baa2bd` |
| `objectstack-ai#14474` | 1/1 | 1 | 0 | 0 | 0 | `df657d9df` |
| `objectstack-ai#14667` | 2/1 | 2 | 0 | 0 | 0 | `dc7c226b9` |
| `objectstack-ai#14678` | 2/1 | 2 | 0 | 0 | 2 | `73ad0bba7` |
| `objectstack-ai#14683` | 2/2 | 2 | 0 | 0 | 0 | `96326040f` |
| `objectstack-ai#14723` | 1/1 | 1 | 0 | 0 | 0 | `65846bc46` |
| `objectstack-ai#14745` | 1/1 | 0 | 0 | 1 | 0 | — |
| `objectstack-ai#14748` | 1/1 | 1 | 0 | 0 | 1 | `92b5d7f00` |
| `objectstack-ai#14758` | 15/5 | 15 | 0 | 0 | 1 | `84199cb87` |
| `objectstack-ai#14760` | 6/2 | 6 | 0 | 0 | 2 | `ee32e1cb8` |
| `objectstack-ai#14864` | 3/3 | 3 | 0 | 0 | 3 | `066dd3bd0` |
| `objectstack-ai#14878` | 2/1 | 2 | 0 | 0 | 1 | `29db3cd2a` |
| `objectstack-ai#14908` | 3/2 | 3 | 0 | 0 | 0 | `d5cbb44f3` |
| `objectstack-ai#14921` | 2/1 | 2 | 0 | 0 | 0 | `c1d274de7` |
| `objectstack-ai#15063` | 2/1 | 2 | 0 | 0 | 0 | `ad35745e8` |
| `objectstack-ai#15068` | 2/2 | 2 | 0 | 0 | 4 | `8744de9e9` |
| `objectstack-ai#15071` | 5/2 | 5 | 0 | 0 | 0 | `cf6e0a193` |
| `objectstack-ai#16610` | 3/1 | 3 | 0 | 0 | 0 | `316a20fc5` |
| `objectstack-ai#16649` | 4/1 | 4 | 0 | 0 | 0 | `44c917a47`, `613bfbd3d` |
| `objectstack-ai#16755` | 1/1 | 1 | 0 | 0 | 0 | `44c849c7d` |
| `objectstack-ai#16758` | 1/1 | 1 | 0 | 0 | 0 | `6e9bee640` |
| `objectstack-ai#16783` | 1/1 | 1 | 0 | 0 | 0 | `854639b31` |
| `objectstack-ai#16919` | 1/1 | 1 | 0 | 0 | 0 | `2cd4c548e` |
| `objectstack-ai#17038` | 1/1 | 0 | 0 | 1 | 0 | — |
| `objectstack-ai#17039` | 1/1 | 1 | 0 | 0 | 0 | `edf59e359` |
| `objectstack-ai#17041` | 2/2 | 0 | 0 | 2 | 0 | — |
| `objectstack-ai#17114` | 2/2 | 2 | 0 | 0 | 2 | `4af758d47` |
| `objectstack-ai#17147` | 1/1 | 1 | 0 | 0 | 0 | `aaacf1d5c` |
| `objectstack-ai#17148` | 1/1 | 0 | 0 | 1 | 0 | — |
| `objectstack-ai#17195` | 1/1 | 1 | 0 | 0 | 0 | `d2c1d1980` |
| `objectstack-ai#17219` | 1/1 | 1 | 0 | 0 | 0 | `706ad0fcc` |
| `objectstack-ai#19364` | 2/2 | 2 | 0 | 0 | 0 | `ada701220` |
| `objectstack-ai#19394` | 5/2 | 5 | 0 | 0 | 0 | `0862063ba` |

Every cited sha matches exactly one object (`git rev-parse
--disambiguate`, count 1 for each of the 95), is a commit, has one
parent, and is an ancestor of the base (`merge-base --is-ancestor`, exit
0 for all 95). The checkout is not shallow (`--is-shallow-repository`
false), and the control leg `13a6cb4ad` exits 0 too.

**Numbers with more than one anchor, by site:**
- `objectstack-ai#10243` (40 sites): `266436a7f` for the 26 sites that describe the
2026-08-23 ruling it implements (the enablement door joins the
`manage_metadata` write set, with the `trigger` exclusion), and
`02b41232d` for the 14 that name the leak itself (「the leak commit
02b4123 measured」). That commit recorded the measurement over HTTP and
says it is part of that card.
- `objectstack-ai#16649` (4 sites): `613bfbd3d` for the first half (the fourteen
remaining `boot-refusal` rows registered) and `44c917a47` for the second
(the face refusal widened to every published package, and `boot-refusal`
retired).
- `objectstack-ai#12176`, `objectstack-ai#12194`, `objectstack-ai#12195`: the stages of one ruled retirement.
`311433f6b` is stage 1 (the item-name grammar refused at the publish
door) and `7986d973f` is stage 3 (the compound arities un-mounted).
These are the anchors the spec stages gave.

**Wordings to check, each true of its commit:**
- `objectstack-ai#10293` (3 sites) cited the p1 flake whose signature had the
expected-noise lines lifted into it. They now read 「(a vitest teardown
race, fixed by commit 92a69d8)」. `92a69d813` names that number in its
subject and fixed the flake by disarming vitest's console-forwarding
teardown race, which is why the noise pointed the dispatch at the wrong
mechanism.
- `objectstack-ai#16755` and `objectstack-ai#16783` each cited an open PR that held a file at the
time. They now read 「the change that landed as commit 44c849c held
that file」 and 「then held by the change that landed as commit
854639b」. Each commit's diff edits the named file
(`domains/automation.ts`, `seed-loader.test.ts`).
- Quoted rulings keep their words.
`dispatcher-plugin.declared-5xx-prose-withhold.test.ts:13` and
`dispatcher-plugin.declared-user-message.test.ts:35` quote the
2026-08-27 ruling, and
`dispatcher-5xx-demoted-code-withhold.test.ts:281` quotes an older note.
There the commit stands in an editorial bracket (`[commit 79c46da]`,
`[commit 0783d7b]`) in place of the number.
- `objectstack-ai#9934`'s 「second constraint」 and 「third constraint」 now read 「the
ruling's second constraint, commit 79c46da」. That commit's own diff
calls status-agnosticism 「the ruling's second constraint」.
- `domains/packages.ts:841` read 「declares, since objectstack-ai#19364:」 above the
`enabled` line, but that line predates `ada701220` (objectstack-ai#19364's commit). It
now reads 「declares — a key commit ada7012 kept rather than retired:」.
- `route-ledger.ts:288`: 「objectstack-ai#16758 filed the second kind」 now reads
「Commit 6e9bee6 gated the second kind」, because that commit added the
row census after the index-slice incident the sentence goes on to
describe.
- `flow-clone.ts:7` and `domains/automation.ts:2403` cite `e170b0ae5`
for objectstack-ai#11513: the commit that landed 「lock package-declared permission
sets at the save door; clone to customize」, whose changeset names the
number.

## The sites left

**No deciding commit, or a literal reader (8 sites):**
- `api-exposure.ts:108` (objectstack-ai#6259): `api-exposure.test.ts:152` splits this
`@param` block on the literal `'objectstack-ai#6259'`, so rewriting the comment would
change what the test measures. Its deciding commit is `6968885ef`, which
the three test-comment sites of the same number now cite.
- `domains/mcp.ts:360` (objectstack-ai#8722): a wider contract change 「archived
unscheduled」. It never landed, so no commit decided it.
- `domains/meta-state-plural-tolerance.test.ts:130` (objectstack-ai#10179): an untaken
option on a tracking card. The only commit naming the card, `53a48c93f`,
recorded the opposite state.
- `package-door-namespace-conflict-code.test.ts:30` (objectstack-ai#14745): a residue
item on a review card. The only commit carrying the token is the one
that added this file.
- `route-ledger.conformance.test.ts:33` (objectstack-ai#17038): an ablation measured
on a PR whose squash commit, `6a7910abb`, neither records nor performs
it.
- `route-ledger.conformance.test.ts:38` and `route-ledger.ts:300`
(objectstack-ai#17041): a maintainer decision the lines call open.
- `security/artifact-granted-permissions.test.ts:291` (objectstack-ai#17148): a
question the line itself says is unsettled.

**Held with `domains/meta.ts` (20 sites), anchors verified for the
follow-up:** `objectstack-ai#8726` `:116` to `e783e163d`; `objectstack-ai#8848` `:200`, `:1398` to
`4fc4a3c0b`; `objectstack-ai#8919` `:1247` to `b5378550e`; `objectstack-ai#10340` `:1309` to
`26f3588fb`; `objectstack-ai#10503` `:14`, `:1143`, `:1159`, `:1250`, `:1308` to
`67ceb9aef`; `objectstack-ai#10888` `:1337` to `d806081dd`; `objectstack-ai#11006` `:103` to
`cccbe51bf`; `objectstack-ai#12194` `:831`, `:1050`, `:1173` to `311433f6b`; `objectstack-ai#12195`
`:819`, `:827`, `:1046`, `:1167`, `:1960` to `7986d973f`. PR objectstack-ai#20615's
one hunk there is at `:1874`, disjoint from these lines, but the rule is
file-level.

**String sites kept as tokens (100).** 95 are test titles and test-code
strings in 43 files. Five are non-test strings: the `route-ledger.ts`
`note` fields at `:435`, `:441` and `:505`, a string at
`dispatcher-error-vocabulary.ts:349`, and the enablement door's refusal
text at `domains/activation-gate.ts:279`, which ends 「(objectstack-ai#10243).」 (see
Acceptance notes).

## Mechanical guard: no code token moves

The check compares the TypeScript parser's leaf tokens (TypeScript
6.0.3, JSDoc nodes excluded, so template literals are read in context)
of each touched file at base `eb4b17c346` against the working tree at
`a5cdfd8a46`, over all 118 touched `.ts` files. Controls mutate the head
text in memory only, so nothing on disk moved for them.

- Real run: 301,081 base tokens, **0 files with a token change** (exit
0).
- Comment-insertion control (`domains/activation-gate.ts`): 0 files
changed (exit 0).
- Code-insertion positive control (a declaration in the same file):
DIFFER at token 34 (exit 1).
- String positive control (`(objectstack-ai#10243)` to `(objectstack-ai#10244)` inside the kept
refusal string): DIFFER at token 339 (exit 1).

Line balance: every touched file is +N/−N (508/508), and every line
count is equal at base and head. A raw scan of the 119 changed files for
control bytes finds none.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/runtime` is included, in PR objectstack-ai#20609's form and level. It
says only that the provenance comments were re-anchored.

Measured on the built package: `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After `pnpm --filter @objectstack/runtime build`, the
rewritten docblocks reach `dist`: for example `e2798fab7` appears 3
times and `68f5eccb1` 6 times in `dist/index.d.ts`, and `f19475c0a` 4
times in `dist/index.js`. The positive control, the unchanged sentence
「drags `@libsql/client` (native bindings included)」 of the same
`turso-driver-factory.ts` docblock, is in `dist/index.d.ts`, and a
negative control phrase appears nowhere. The only dead number left in
`dist` is the kept refusal string's `objectstack-ai#10243`.

## Gates (head `a5cdfd8a46`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its disclosure, verbatim, from each locked run at this
head:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 5s · declare it in the PR body · pnpm --filter @objectstack/runtime build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 99s (1m39s) · declare it in the PR body · pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 6s · declare it in the PR body · pnpm --filter @objectstack/runtime exec vitest run --project repo --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 8s · declare it in the PR body · pnpm --filter @objectstack/runtime typecheck
```

The dependency closure and the whole workspace were built first, at the
merge head `ca6d13d6ab`, the same way: `turbo run build
--filter='@objectstack/runtime...'` (30 tasks, exit 0) and `turbo run
build --filter='./packages/*' --filter='./packages/*/*'` (71 tasks, exit
0). `a5cdfd8a46` differs from that head only in `domains/meta.ts`, which
went back to base bytes, and `@objectstack/runtime` was rebuilt at
`a5cdfd8a46`.

- **Tests:** `vitest run --project local`: 288 files, 4,190 tests
passed, 1 skipped. `--project repo` (which holds the touched
`action-owner-key-single-source.test.ts`): 3 files, 727 tests passed.
Together they cover every touched test file.
- **Typecheck:** `pnpm --filter @objectstack/runtime typecheck` exits 0.
`tsc --listFiles` counts 82 `src` files (no tests) under `tsconfig.json`
and all 291 test files under `tsconfig.test.json`, which
`check:test-typecheck` judges: 27 files, 190 errors, 68 pinned
signatures held.
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at `a5cdfd8a46` (2026-09-29T09:23:06Z to 09:23:36Z). A narrowed
run over the 118 touched `.ts` files through eslint's API agrees: 118
linted, 0 ignored, 0 errors, 0 warnings.
- **Citation judging:** `node scripts/check-issue-citations.mjs --base
origin/main` exits 0. The diff-scoped run judged 23 citations across 28
files, and all 23 resolve. These are the live numbers that stay on
rewritten lines. It defers `*.test.ts`, so the added-minus-removed count
over the whole diff covers the rest: 0 numbers added.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `a5cdfd8a46` derived 67
families, the same set as at the merge head. All 67 exit 0. `--ran`
reads 「67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN」.
- At the merge head, `check:dual-build-cjs-loads` and
`check:type-check-debt` first exited 3 (PREREQUISITE NOT MET) on a
partly built workspace. After the whole-workspace build both exited 0,
and both exit 0 at the final head.
- Among them: `check:doc-authoring` (the sibling prose-id baseline
holds, 810 pinned sites, no growth), `check:nul-bytes` (9,250 files, no
raw control bytes), `check:route-ledger-census`,
`check:dispatcher-error-vocabulary` and `check:issue-citations`
(self-test, 114 cases in 8 batteries).
- **Artifact rosters:** 38 of the 41 non-self-test roster rows exit 0 at
the merge head. The other three, `check-closing-target-claim`,
`check-partof-closing-keyword` and `check-single-claim-paths`, answer
「NOT WIRED」 (exit 2) without a pull request's context, and are run
against this PR and reported on the card.

## Hypotheses (measured first)

- **H0 holds.** The filtered census answers 217 dead sites at
`eb4b17c346` (29 files, 59 numbers), equal to the card's count at
`f11b5f20a2`: no drift.
- **H1 holds, with the listed exceptions.** After the rewrite the
filtered census answers 23: the 20 sites held with `domains/meta.ts` for
an open PR, and 3 deliberate ones (a literal reader, a card never
landed, an open decision). The supplementary reading adds 5 test-comment
sites of the same two kinds.
- **H2 holds, by the token guard.** A comment-stripped comparison of
every touched file (the parser's leaf tokens, JSDoc excluded) is empty,
and its controls fire. The emitted `dist` is not byte-identical, because
the docblocks ship, which is why the changeset is `patch`.

## Acceptance notes

- **The held file.** The claim's read (07:51Z) and this stage's first
read of the open PRs' file lists (08:06:32Z, 8 open PRs) found none
touching `packages/runtime/src`. PR objectstack-ai#20615 opened at 08:12:40Z and edits
`domains/meta.ts`. The re-read at 09:07:08Z (7 open PRs) found it, and
it is the only open PR touching the package. The file went back to its
base blob in `a5cdfd8a46`, and `git hash-object` equals `b4ddb362cc`,
the blob at the base and at `origin/main`. The 20 anchors above are
ready for the follow-up once that PR lands.
- **Form D, not touched here.** `domains/activation-gate.ts:279` is part
of the enablement door's refusal message and ends 「(objectstack-ai#10243).」. An author
sees it, so it is ruling D's (no number, the lesson in words), a string
change outside this comment-only scope. It needs a form-D carrier. The
other four non-test string sites are ledger `note` data and a gate's own
string.
- **The grammar does not read a slash-joined number.** `CITATION_RE`
refuses a `#` preceded by `/`, so the second number of `#A/#B` is never
judged. In `packages/runtime/src`, 3 such dead numbers exist (`objectstack-ai#10630`,
`objectstack-ai#12281`, `objectstack-ai#12194`), and all 3 are rewritten here. The other 36 distinct
slash-joined numbers there were probed by REST and answer 200. One more
dead one, `objectstack-ai#17219`, stands slash-joined inside a test title, a string,
and is kept. This is the same shape as PR objectstack-ai#20612's slash-joined
`objectstack-ai#5775/objectstack-ai#6629`. It is noted, not filed.
- **Outside the scope and the census surface.**
`packages/runtime/vitest.config.ts:54` cites `objectstack-ai#17853`, which answers
404. The file is outside `src/**`, so it is left for whoever owns the
package's config. The other numbers there, and those in `tsup.config.ts`
and `README.md`, answer 200.
- **Base.** The branch merged `origin/main` once (`ca6d13d6ab`, merging
`c1d8051e0a`) before the `--base origin/main` run, as the dispatch
orders. That merge brought PR objectstack-ai#20609's and PR objectstack-ai#20612's landed stages and
touched none of this diff's files. `origin/main` has since moved to
`ed6f7348f9`, one commit that touches only `packages/cli`, so there was
no second merge.
- **Anchors shared with the landed stages.** 24 numbers keep the anchor
the spec, lint or service-messaging stages already gave them, for
example `f19475c0a` (objectstack-ai#14143), `b003cf2e8` (objectstack-ai#13657), `311433f6b`
(objectstack-ai#12194), `8e13ca876` (objectstack-ai#6206) and `17d095413` (objectstack-ai#6363).

## Deviations

- Three changed lines hold only a slash-joined dead number, beyond the
census's sites (see Acceptance notes). Five more are the other half of a
rewritten sentence (listed under What changed).
- Commit trailers are AGENTS.md's model-free pair (`Claude-Session` plus
`Co-authored-by: Claude`), and the pre-push trailer check passed on
every push. The merge commit carries git's default message.

---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_

---------

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… one scheme, and refuse a secret that did not resolve (objectstack-ai#20640)

Fixes objectstack-ai#20628
Clause-②: yes (widening). `@objectstack/core` gains the exported scheme
⇒ at least `minor` for `core`.

## What changed

A flow `http` node's `signingSecret` is declared as "HMAC-SHA256 secret
→ X-Objectstack-Signature", and no arm is named. Only the durable outbox
arm signed. The inline arm, and the durable arm's fallback when no
messaging HTTP outbox is wired, sent no signature header, and the run
still reported success. After this PR the key means one thing on every
arm.

- **One scheme, moved to `@objectstack/core`** (the seat's ruling on the
claim). Two new exports on the `@objectstack/core` root come from
`packages/core/src/security/http-signature.ts` through
`packages/core/src/security/index.ts`:
- `signHttpBody(body: string, secret: string): string` returns `sha256=`
plus the lowercase hex HMAC-SHA256 of the exact body bytes.
  - `HTTP_SIGNATURE_HEADER` is `'X-Objectstack-Signature'`.
- `@objectstack/runtime` re-exports the core root with `export *`, so
both names appear there too.
- **`@objectstack/service-messaging` keeps its published names**,
`signHttpBody` and `HTTP_SIGNATURE_HEADER`. They are now re-exports of
the core bindings. `http-sender.ts` re-exports them under the
module-internal names the two outboxes import (`signBody` /
`SIGNATURE_HEADER`). No second implementation remains, and nothing it
publishes is removed or renamed. A test pins `messaging.signHttpBody ===
core.signHttpBody` against the built packages.
- **`http-nodes.ts`, the inline arm** (also the no-outbox fallback)
sends `X-Objectstack-Signature` whenever `signingSecret` is set. The
value is `signHttpBody` over the exact string it passes as `fetch`'s
`body`, or over the empty string when there is no body.
- **The refusal:** a non-empty authored `signingSecret` that resolves to
no value in the run fails the node before either arm, so nothing is sent
or enqueued. The full condition is below.

## Which bytes each arm signs

- **Outbox arm (unchanged).** `MemoryHttpOutbox.enqueue` and
`SqlHttpOutbox.enqueue` sign `deliveryBody(payload)` at enqueue, and
`sendOnce` posts `deliveryBody(payload)`. The node passes `payload: body
?? {}`:
  - an object body is sent as its `JSON.stringify`;
  - a string body is sent verbatim;
  - no body is sent as `{}`.
- **Inline arm and the no-outbox fallback.** These use the node's own
serialization:
- a non-null body is sent as `JSON.stringify(body)`, so a string body
goes out JSON-quoted;
- otherwise no body is sent, and the signature is over the empty string.
- **The two serializations differ** for a string body and for no body.
Each arm signs what it sends. On every arm the pins check at a real
local receiver that the received header equals
`signHttpBody(receivedBytes, secret)`.
- **The empty-body HMAC is pinned as a literal** in both the core test
and the node test (`sha256=28c9179f…bd5187f7` under the test secret). So
the fallback can't drift to signing `{}` or `null` while it sends
nothing.

## The refusal condition, from the measurement

What `signingSecret` becomes after `interpolate(...)` and the contract
parse. The "after" column was measured in a scratch run at the fixed
head. The two refused rows were also measured on `main`.

| authored `signingSecret` | resolves to | on `main` | after |
|---|---|---|---|
| absent | absent | no header | no header |
| `''` | `''` | no header | no header: the unsigned-on-purpose spelling
(the cleared form PR objectstack-ai#20615 defines), on every arm |
| a literal | the literal | inline: no header; outbox: signed | signed
on every arm |
| a whole `{token}` with no value in the run | `undefined` (the parse
accepts it) | sent with no header, `success: true` | **refused** |
| a `{token}` whose value is `''`, or several tokens that all render
empty | `''` | sent with no header, `success: true` | **refused** |
| a `{token}` whose value is `null` or a number | a non-string | refused
by the contract parse, naming `config.signingSecret` | unchanged |
| `k_{token}` with no value | `'k_'` | inline: no header | signed with
`k_`. The receiver's check then fails, so the error is loud there. The
executor can't tell this apart from a real literal. |

- **The rule:** refuse when the authored value is a non-empty string and
the resolved value is `undefined` or `''`.
- **The refusal is `refuseNode`**, a guard refusal. That is the same
class as this file's `url` refusal and the objectstack-ai#3810 collapsed-filter
precedent, so a fault edge does not route it. A new row in
`guard-refusal-inventory.test.ts` pins this.
- **It runs before the durable branch.** So the outbox arm also refuses,
where before it enqueued the delivery unsigned.
- **The message names the key** and the unsigned-on-purpose spelling,
and it carries no tracker number.

## Evidence (final head `62f989f1c`)

- **Measured before the fix, RED.** Commit `b9a7d9115` adds only the
pins, on the unfixed executor at `542670da6`.
- `http-node-signing.test.ts`: 19 failed, 8 passed. Every signing pin
failed on all three in-process arms: inline; durable with no messaging
service; durable with a `MessagingService` and no outbox.
- The failures read `AssertionError: no X-Objectstack-Signature arrived:
expected undefined to be type of 'string'`. The GET pin read `expected
undefined to be 'sha256=28c9179fd9763c0e7d41dc5241d9d7…'`.
- Both refusal pins read `expected true to be false` on each arm and on
the outbox arm. The run succeeded and the request left unsigned.
- The 8 greens were the controls: no key and `''` on the three arms,
plus the outbox arm's signature and its `''`.
- `guard-refusal-inventory.test.ts`: the new row failed (1 failed, 15
passed), because the fault edge routed the failure.
- **After the change.** The same two files, plus `http-nodes.test.ts`
and `http-delivery-outcome.integration.test.ts`: 4 files, 56 passed.
- **Ablation.** The fix was committed first, and the restore had its own
trap.
- `scripts/ablation-replace.mjs` replaced the inline arm's signing
expression with `? headers`: anchor 1 → 0, blob `2137f1ab2056` →
`061481dd31ee`.
- `http-node-signing.test.ts` then gave 12 failed, 16 passed: exactly
the 4 signing pins × 3 in-process arms. The quoted failures were `no
X-Objectstack-Signature arrived: expected undefined to be type of
'string'` and `expected undefined to be
'sha256=28c9179fd9763c0e7d41dc5241d9d7…'`.
- Restore: blob `2137f1ab2056` equals HEAD, and `git diff HEAD` is
empty. The trap proved it a second time.
- The ablation ran at `14828798f`. `git diff --stat 1482879 62f989f`
on `http-nodes.ts` and the test file prints nothing.
- There is no build leg: the subject is service-automation's own `src/`,
which vitest reads directly.
- **Package suites at `62f989f1c`**, after merging `origin/main` at
`3f45b6cc1`:
  - `@objectstack/service-automation`: 153 files, 1895 tests passed.
  - `@objectstack/service-messaging`: 46 files, 507 passed.
- `@objectstack/core` `--project local`: 57 files, 1525 passed.
`http-signature.test.ts` alone: 3 passed.
- `typecheck` on the three packages: exit 0. Both test layers compile,
with no new debt.
- **Whole tree.** `pnpm build --concurrency=2` at `62f989f1c`: 72 of 72
tasks succeeded. That includes every package downstream of
`@objectstack/core` (the `...@objectstack/core` consumer direction). So
the two new root names collide with no `export *` consumer
(`@objectstack/runtime`, `@objectstack/plugin-hono-server`).
- **Gates at `62f989f1c`.**
- `dispatch-gates --commands` derived 65 families. All 65 ran, and every
exit code was captured before any pipe: 65 exit 0. `--ran` reconciles to
"65 run, 0 NOT-MEASURED (a DERIVED zero)".
- The ⛔ artifact rosters under paths in this diff: 4 run, 4 exit 0
(`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`). Also run:
`check:published-readme-exports`, exit 0.
  - `pnpm lint` (repo-wide eslint, `--no-inline-config`): exit 0.
- **Not measured locally; CI runs these:**
- the 5 path-scheduled CI jobs (the Test Core shards, Temporal
Conformance, the Dogfood shards);
  - the 11 wide-population families;
  - the 6 families whose argv carries a workflow-only value.

## Acceptance notes

- **Durable `GET` never delivers.** This is outside this card, a
different defect, so it is not fixed here. It is recorded for the seat.
- A `durable: true` node with `method: 'GET'` enqueues `payload: {}`.
The dispatcher's send then refuses a GET that has a body, with `Request
with GET/HEAD method cannot have body.`.
- So the row stays pending and retrying, and it never reaches the
receiver. The run meanwhile reports success.
- Measured at the executor seam with a real `MessagingService`,
`MemoryHttpOutbox` and `HttpDispatcher` and a local receiver. After one
tick the row showed `status: pending`, `attempts: 1`, that error, and
the receiver had nothing.
- No public door was measured and no real producer is named, so it is
not filed.
- A side effect: the outbox arm would sign a bodyless GET over `{}`, not
the empty body. That is moot while such a request cannot be sent.
- **`flow-credential-projection.ts` docblock.** It says the durable arm
hands `signingSecret` to the outbox, "which signs every delivery". That
is still true, but now incomplete, because the inline arm signs too. It
is not edited here: the file is outside this card's file surface.
- **File surface.** `guard-refusal-inventory.test.ts` sits outside
`builtin/`. I read "`http-nodes.ts` and its tests" as including the
inventory row that drives the http executor. The inventory's own header
asks that each new guard be added there.
- **Header case.** The inline arm mirrors the outbox: author headers
first, then the signature under the exact name
`X-Objectstack-Signature`. That overrides an author header with the same
casing. A differently-cased author header would travel beside it, on
both arms alike. Noted only.
- **Behaviour change to call out:** a durable node whose authored secret
does not resolve now refuses. Before, it enqueued an unsigned delivery.

## Cross-lane

`packages/core` belongs to `domain:engine`. The new exports, exactly:
`signHttpBody` and `HTTP_SIGNATURE_HEADER` on the `@objectstack/core`
root, which `@objectstack/runtime` also carries through its `export *`.
No existing core export changed.

## Changeset

`.changeset/20628-http-node-signs-both-arms.md`: `@objectstack/core`
minor, `@objectstack/service-automation` and
`@objectstack/service-messaging` patch. It carries the `Clause-②: yes
(widening)` line.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants