Skip to content

fix(metadata-protocol)!: a metadata body's stored content hash is served and compared only in keyed form, never copied, never evaluated (#21207) - #21436

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21207-exit-two-keyed-served-hash
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21207-exit-two-keyed-served-hash

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21207
Clause-②: yes (narrowing)

Exit two of #21207, under the maintainer's ruling B (5942670275) and its execution forks A / A / A (5950183039). One PR closes the whole hash-serving exit family enumerated in the exit-two report 5946577002 (members 1 to 13), plus one member this PR's own measurement found (14, below). Exit one already landed as #21228.

The stored content hash of a metadata body stays the canonical hash at rest: the repository contract, its producers, the filesystem layer and the parent links are untouched. What changes is what a caller is given and what a caller may evaluate:

  • Served — every door that hands the hash out hands out a keyed digest of it: the crypto provider's, or, while no provider is registered, one under a process-scoped ephemeral key. The one exception is the MCP stdio reader, which omits the two hash columns on a host with no provider.
  • Inbound — every door that takes a version token back compares it in keyed form against the current stored head and hands the stored value to the repository's own lock. A raw stored hash and a stale token are refused with 409 METADATA_CONFLICT. With no provider, a token this process served is accepted, and an empty, withheld, raw or stale token is refused the same way.
  • Evaluated — filter, sort and group on the two stored content-hash columns are refused with 400 INVALID_FIELD before the engine, at the data door, the MCP stdio reader and the analytics door. A data-door search over the two stored-metadata tables no longer scans them.
  • Copied — the ledger snapshot and diff, the activity copy and the decision-audit note carry no hash. os migrate audit-metadata-bodies (dry run by default, idempotent) now also rewrites the copies already at rest. The version history stays the lineage.

Disclosure discipline: this body names classes, doors, roles, codes and statuses only.

The exit family, member by member

# Exit (class) Door(s) Disposition
1 save receipt version token /meta save door, runtime dispatcher save door keyed at the protocol; both transports inherit it
2 publish receipt version token /meta publish door keyed
3 package batch-publish version tokens package publish door keyed per element (the stored value stays internal)
4 rollback receipt version token /meta rollback door keyed
5 history read: event hash and parent hash /meta history door keyed per event
6 conflict refusal: text and attributes save, publish, rollback, reset doors keyed values or none
7 decision-audit note of a conflict written by the protocol, served by the /meta audit door and the data door names no hash; a side is (withheld) or null
8 the two stored content-hash columns on both stored-metadata tables data door get and list keyed
9 evaluate shapes on those columns data door filter, sort, group, and search 400 INVALID_FIELD, naming the usable columns
10 MCP stdio engine-only reader bridge query, get and aggregate; the record resource keyed; group, filter and sort refused
11 audit ledger copies plugin-audit writer the two columns are dropped at write time; at rest via the migration
12 activity copies plugin-audit writer same as 11
13 analytics members on those columns analytics door 400 INVALID_FIELD in either role
14 the version history's change note history read, data door, MCP stdio reader, copies see below

Member 14, found by the after-measurement. A draft promotion that stated no message of its own recorded the draft's stored hash in the history row's change note. That note was served by the history read, the data door and the MCP stdio reader, and the audit writer copied it. The fix:

  • The publish door now always states a hash-free message.
  • A note written before this change is served with each quoted hash in keyed form (under the process key while no provider is registered). Only the MCP stdio reader serves (withheld) in its place, on a host with no provider.
  • The note is never evaluated: filter, sort, group and search are refused, and it is refused as an analytics member.
  • Copies withhold the quote, at write time and through the migration.

The history row itself is not rewritten: the history table stays the lineage. This member is outside the ruling's literal enumeration, so it is flagged for the contract review.

Not exits (unchanged): the HTTP cache validator (measured: it never carries the stored hash), and realtime record events (out of scope by the ruling; no public channel route in this repository).

The engine gains one additive read accessor beside setCryptoProvider, for the registered provider's keyed digest. It is read at each use, because a host registers the provider after the kernel starts. It is narrower than the provider itself: no consumer is handed decrypt.

Measured on a real boot, before and after

Composition: showcase + automation + SQLite file database + audit plugin + the three connector plugins. Administrator and member API keys were minted through the key door (201 / 201). The verify harness registers the local crypto provider, as os serve does. Before is base ecb6ca0258; after is this branch.

Door, administrator Before After
save, publish, rollback receipts 200, token equals the stored head 200, token is keyed and is not the stored head
history read 200, every event hash and parent hash a stored hash 200, all keyed, none stored
save and reset doors, raw stored hash sent back 200, accepted 409 METADATA_CONFLICT
save door, served token sent back 200 200
conflict refusal 409, body carries the current stored hash 409, no stored hash
data door list and get, both tables 200, stored values; on a credential-bearing row, the served hash plus the projected body confirm a right guess and reject a wrong one 200, keyed; the guess no longer confirms; stable across reads; a credential-only change still moves it
data door filter, sort, group on the hash columns filter: right guess 1 row, wrong guess 0 rows; group serves stored values 400 INVALID_FIELD on each
data door search over the hash or body column a right hash prefix and a right credential prefix each match their row no match; explicit search fields naming one: 400 INVALID_FIELD
decision-audit note (/meta audit door, data door) carries stored hashes none
ledger and activity copies written after the change carry the stored hashes none (0 rows)
analytics grouped by a hash column 200, serves stored values 400 INVALID_FIELD
MCP stdio reader: query, get, record resource (both tables) stored values keyed
MCP stdio reader: group, filter, sort on a hash column run refused, INVALID_FIELD
history change note (member 14), stock row — served keyed by the history read and the data door; filter and search refused

Member, before and after alike: data door 403 PERMISSION_DENIED, history door 403, ledger 403, analytics 403 PERMISSION_DENIED, and MCP PERMISSION_DENIED on every member.

Copies at rest, measured through the CLI door on a database the base code wrote:

Step Ledger copies with a hash Activity copies with a hash Decision notes with a hash
before 25 of 38 25 of 38 1
dry run (exit 0) unchanged; it reports 53 rows to rewrite unchanged unchanged
--apply --yes (exit 0) 0 of 39 0 of 39 0
second dry run (exit 0) 0 to rewrite 0 to rewrite 0 to rewrite

The 39th row is the ledger copy of the migration's own rewrite of the note, and it carries no hash. The history lineage keeps its 9 stored hashes. On a stock database before the migration runs, the served copies still carry the hash. That is the ruled path: operators run the migration once after upgrading.

Tests

Red first: the new pins were committed on the unfixed tree and run there.

  • metadata-protocol: 25 failed, 5 passed
  • mcp: 11 failed, 3 passed
  • plugin-audit: 14 failed, 88 passed
  • service-analytics: 6 failed, 7 passed

Every red is a door serving or accepting the stored value. The controls stayed green. The member-14 pins and the decision-note copy pin were written after the fix, and their red is shown by ablation legs L06, L10, L15 and L17.

Green, at the fix:

Package Result
metadata-protocol full suite 3013 passed before the merge, then re-run on the touched files after it
objectql full suite 7358 passed; one conformance pin now registers a crypto provider
rest 4982 passed
runtime 5081 passed
mcp 380 passed
plugin-audit 598 passed
service-analytics 3793 passed
cli unit project 3489 passed; the migrate preview integration file 6 passed, 1 skipped (the live PG cell)
dogfood 17 affected files passed, among them the flow, metadata-route, package-authoring, audit-log, activity, MCP and permission-projection files

typecheck exited 0 for metadata-protocol, objectql, mcp, plugin-audit, service-analytics, rest and cli.

Superseded pins updated:

  • Two decision-note pins used to assert that the note carries the caller's token. They now assert the note withholds it.
  • The batch-publish conformance pin asserts a non-empty token with no provider registered. The first cut changed its composition. It is back to its base bytes and passes as written.
  • The absent-database audit pin that fix(cli,runtime): one-shot CLI boots run no seed loader and arm no lifecycle sweep; every no-write mode boots read-only #21432 added (a dry run of the audit-metadata-bodies migration on a database that does not exist) counted two tables unread. The audit now also reads the decision-audit trail, so the pin counts every audited table: three, each named, none scanned, exit 1. A control that removes the decision-audit table from the run turns it red.

Ablations. The fix was committed first. Each of 17 legs went through scripts/ablation-replace.mjs: the anchor hit once, the blob changed, the targeted pin went red, and the restore showed blob == HEAD with an empty git diff HEAD.

Leg Mutation Red
L01 receipt served raw 8 of 11
L02 raw token accepted inbound 2 of 11
L03 history served raw 3 of 11
L04 conflict carries the stored hash 2 of 11
L05 note carries the token 1 of 11
L06 publish door states no message 1 of 11
L07 data-door columns served raw 5 of 27
L08 data-door evaluate shapes unrefused 12 of 27
L09 search not narrowed 5 of 27
L10 quoted hash in a note served raw 2 of 38
L11 MCP columns served raw 3 of 16
L12 MCP evaluate shapes unrefused 8 of 16
L13 analytics unrefused 7 of 14
L14 writer copies the hash 4 of 93
L15 writer copies a decision note's hash 1 of 93
L16 migration keeps the columns 7 of 12
L17 migration keeps a note's hashes 5 of 12

Patch round (CI falsified option A). The fix lands at 7660d811a7. Validation and ablation results are in the os-dev-report for this round. The SDK and CLI reset-door pins pass unedited. Restoring the empty token, with dist rebuilt, turns them red again: 3 of 20 and 6 of 20, the exact CI failures.

Gates. At 1ad5a0099e:

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 84 commands. All 84 ran, every exit code recorded, all 0.
  • --ran reconciles 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN.
  • check:error-code-casing and check:nul-bytes exited 0.
  • pnpm lint (the whole repository) exited 0.

Gate hygiene this needed:

  • the new pinned engine doubles recorded through check-engine-double-contract --write;
  • one test double now holds the caller's bound;
  • one where-matcher now refuses the combinators it does not implement;
  • the migration reads the decision-audit code through an operator-form predicate, because it is a read and not a stamp;
  • the persisted audit vocabulary is marked in the pins.

Acceptance notes

  • No crypto provider registered (option A falsified by CI, replaced). The first cut served an empty version token on a host with no crypto provider. CI falsified that: two real reset-door pins, one in the SDK and one in the CLI, showed that every save then handed out the same empty token. A client that sends no pin for an empty token turned a pinned reset into an unpinned one, so the optimistic lock failed open. Replaced in this PR: while no provider is registered, the doors key under a process-scoped ephemeral key (32 random bytes drawn on first use, never written, logged or served). A token is always served, differs when the content differs, and is never the stored hash. An empty or withheld token sent back is refused with 409 METADATA_CONFLICT, never read as "no pin". A token held across a restart, or across a provider's first registration, is refused once with the same 409. No stored value carries a served token, so nothing persisted dies with the key. The MCP stdio reader has no version-token door; it still omits the hash columns on a host with no provider.
  • Where the hash-column list lives. The family's natural home is beside the body column's primitives in the spec kernel module, which is outside this claim. metadata-protocol, mcp, plugin-audit and service-analytics each name the same columns. The family enumeration pin holds metadata-protocol's list equal to the columns the two object definitions declare, and each other package's copy is pinned by its own behaviour tests.
  • Stale spec descriptions. The spec's descriptions of the save, publish and batch-publish tokens still say the token is "currently emitted as" an unkeyed hash. The format is declared outside the contract, so this is prose drift for the spec seat.
  • metadata-core's base conflict text still prints both stored values. No door serves it: every door converts the conflict, and the revert door withholds undeclared failures. So it is untouched.
  • Serial constraint. feat(automation): a flow's credentials live in a write-only channel on the secret seam, not in its stored definition (#20790) #21377 landed while this branch was in flight, and origin/main was merged in (41a3c8df15). It adds no hash exit. origin/main was merged again (8ca49662e8, which carries fix(cli,runtime): one-shot CLI boots run no seed loader and arm no lifecycle sweep; every no-write mode boots read-only #21432), and that PR's absent-database audit pin was stacked with this one (see Superseded pins).

Changeset: minor, with a BREAKING banner and one ADR-0087 disposition (not-required (no-migration-prescription)). It states the three consequences: a held token gets one 409; filter, sort and group on the hash columns and the change note answer 400; operators run the extended migration once, dry run first.

An independent contract review is owed before landing, per the ruling.


Generated by Claude Code

claude added 10 commits October 2, 2026 12:29
…tored content-hash exit family before the fix (#21207)

Pins written red-first: the served form of the stored content hash at every
door, inbound version tokens in keyed form, the evaluate refusals, the
write-time copies and the extended at-rest migration.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…erve the stored content hash only in keyed form (#21207)

The engine gains a read accessor for the registered crypto provider's keyed
digest. The protocol serves keyedDigest(stored) on the save, publish,
batch-publish and rollback receipts, the history read and the data door's two
stored-metadata tables; compares inbound version tokens in keyed form on the
save and reset doors; answers conflicts with keyed values or none; writes a
hash-free decision-audit note; and refuses filter, sort, group and search over
the hash columns (and search over the body column) before the engine.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…content-hash exit family (#21207)

The MCP stdio reader serves the two hash columns keyed (or not at all) on
query, get and the record resource, and refuses group, filter and sort on
them. Analytics refuses them as members. The audit writer's copies drop them,
and os migrate audit-metadata-bodies drops them from the copies already
written and withholds the hashes in conflict notes and their copies. The
family enumeration pin gains every member.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…eded pins, changeset (#21207)

The withheld marker reads (withheld) so no refusal opens with a bracketed tag;
the two pins that asserted a conflict note carries a hash, and the batch
publish conformance pin that asserted a token with no provider registered,
follow the new contract. The changeset states the three caller and operator
consequences.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…ry change note that quotes a stored hash joins the family (#21207)

A draft promotion with no message of its own recorded the draft's stored
content hash in the history row's change note, served by the history read,
the data door and the MCP stdio reader and copied by the audit writer. The
publish door now states a hash-free message; a stored note is served with
each quoted hash keyed (withheld with no provider), is never evaluated, and
its copies withhold the quote at write time and at rest.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…it quotes (#21207)

The extended migration's rewrite of a conflict note is itself an audited
update, and its ledger copy carried the old note's hashes back into the
ledger: one apply left one copy to rewrite. The writer now withholds a quoted
stored hash in any copied decision-audit note, so one apply converges.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…ash family (#21207)

Record the new pinned engine doubles in the engine-double ledger, read the
decision-audit code column through an operator-form predicate (a read, not a
stamp), and mark the persisted audit vocabulary in the new pins.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…lds the caller's bound (#21207)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…refuses combinators it does not implement (#21207)

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

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 7 package(s): @objectstack/cli, @objectstack/mcp, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/plugin-audit, @objectstack/rest, @objectstack/service-analytics, touching 87 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 94a8761a8732c8c27a1fe59c9827ddb86e825b08.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 71 pages)
  • 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 — 63 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 94a8761a8732c8c27a1fe59c9827ddb86e825b08 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 94a8761a8732c8c27a1fe59c9827ddb86e825b08

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

claude added 3 commits October 2, 2026 16:45
…keyed under a process-scoped ephemeral key, never empty (#21207)

The first cut served an empty version token on a host with no crypto
provider. Every save then handed out the same empty token, and a client
that sends no pin for an empty token turned every pinned reset into an
unpinned one: the optimistic lock failed open. CI's real reset-door pin
caught it.

The doors now key under the provider when one is registered and, while
none is, under 32 random bytes drawn once per process and never written
anywhere. A token is always served, differs when the content differs, is
never the unkeyed stored hash, and no empty or withheld token equals it.
A token held across a restart, or across a provider's registration, is
refused once with 409. The no-provider refusal branch is gone, and the
batch-publish conformance pin is back to its base bytes: it passes as it
was written.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
Brings in #21432 (one-shot CLI boots read-only), whose exit-1 pin on
audit-metadata-bodies counts every audited table, and the rest of main
since the branch point. Auto-merged; protocol.ts and engine.ts merged
without conflict.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…the decision-audit trail included (#21207)

#21432 (#21391) pinned that a dry run of audit-metadata-bodies on an absent
database exits 1 and counts every table it audits as unread. It was
written while the audit read two tables. This branch widens the audit to
the decision-audit trail, so the merged tree reads three. Both intents
are stacked: failures equals the whole audited set, byObject names
exactly those tables, scanned stays 0, exit 1. The set is stated
literally, so a later widening turns the case red rather than passing on
a stale count.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b53bea8da595bab212d688c23e99b943d850db90
Local-runs: none

Reviewed head b53bea8da5, the dispatched head 1ad5a0099e plus two patch rounds. Merge base with origin/main is 53fd35e3e3; net diff 22 files, +2765/−138. Round one (7660d811a7, one commit) answered the dispatched head's red: Test Core (1/6) and (5/6) had failed on two pre-existing pins this PR never edits (packages/client/src/meta-delete-item-carriers.test.ts, the #12181 real reset door; packages/cli/src/commands/meta/delete-reset-carriers.test.ts, the #13024 pins), because with no crypto provider the receipts served an empty token and the first-party SDK and CLI drop a falsy ifMatch rather than send the header (packages/client/src/index.ts:916), so a round-tripped empty token made every pinned write unpinned — the optimistic lock failed open. Round two (8ca49662e8, an automatic merge of origin/main 53fd35e3e3 into the branch, then b53bea8da5, one test file) answered the second red: #21432 (b206403bea) had landed on main after the branch's base with an exit-1 pin that counts the audited tables of os migrate audit-metadata-bodies as unread, written while that audit read two tables; this PR reads a third. The merge's net effect was re-taken here: the merge commit's diff against its main parent is exactly the round-one change (21 files, +2756/−135), the net hunks of the two files main also touched (protocol.ts via 535d1d25ab, engine.ts via 6d728b850f) are identical to the reviewed ones apart from offsets, and the only other net change is that one pin. The #12181 / #13024 files and the objectql batch-publish conformance pin are blob-identical to main.

① Derived judgments

(a) Ruling B, exit by exit — right. Members 1 to 4 (save, publish, batch-publish, rollback receipts) go through one receiptVersion in protocol.ts; member 5 (history read) keys each event's hash and parent hash and serves the change note through serveStoredHashTokens; member 6 (409 conflict refusal) goes through one metadataConflictRefusal at the save, publish, rollback and reset doors, text and attributes keyed or none; member 7 (decision-audit note) names no hash on either side; member 8 (data door get and list) keys both columns through serveStoredMetadataHashColumnRows / serveStoredMetadataHashColumns; member 10 (MCP stdio query, get, record resource) keys through serveStoredMetadataHashes and omits both columns on a host with no provider; members 11 and 12 (ledger and activity copies) drop both columns at write time in audit-writers.ts; member 13 (analytics) refuses; member 14 (change note) is stated hash-free by the publish door, and the batch-publish path reaches the same default through promoteDraftForPublish. Readers of the two hash columns and of the change note were traced across packages/*/src at this head: every remaining mention outside the family packages is an artifact, file, hot-reload or package checksum, or the dormant packages/metadata loader and manager surface, which has zero callers outside that package; sys_metadata_audit declares no hash column. The repository invariant is untouched: no edit to packages/metadata-core/src/repository.ts, to sys-metadata-repository.ts or to hashSpec, and migrateStoredMetadata hands the stored value down through the in-process storedParentVersion member. The base conflict text in packages/metadata-core/src/errors.ts reaches no door: every ConflictError catch converts, and the revert door's catch serves clientFacingFailureText, which withholds a failure that declares no client status. The engine's realtime event projection covers the body only, as the ruling records; this repository has no channel route (pinned in packages/runtime).

(b) Inbound — right. storedParentForToken compares the sent token against the keyed digest of the current stored head, hands the stored value to the repository lock, and refuses a raw stored hash, a stale token, an empty token and a withheld marker with 409 METADATA_CONFLICT; null keeps its "expect no row" meaning and an absent token keeps the pinned last-write-wins default. storedParentVersion is reachable from no door: the REST save door and the dispatcher save door build the request field by field, the spec request schema declares no such member, and every in-process saveMetaItem caller outside the protocol passes no parent at all.

(c) Fork two A — right. The data door refuses filter, sort and group on both hash columns and on the change note before the engine (storedMetadataHashEvaluateRefusal, judged after the existence gates in the body column's order), the MCP stdio reader refuses the same shapes in the same envelope (storedMetadataHashRefusal), and the analytics door refuses either role (storedMetadataBodyAnalyticsRefusal). A dotted path headed by a hash column is caught at both doors. The one evaluate shape the ruling does not name, an aggregation member over a hash column, is closed elsewhere: the spec compatibility table refuses min and max over the string classes and count serves a number, so no member serves the value; the MCP body refusal names aggregations and the hash refusal does not — a wording gap, not a hole. The default search narrowing also drops the stored body column from the scanned set. A search is a substring predicate evaluated over the column, so it is an evaluate surface of the body; the #21120 close-out acceptance names every surface that can "serve, copy or evaluate" a stored body and the maintainer's ruling there refuses the predicate shapes. It is within that family's rulings, declared by the dev, and now a row of the family pin; it deserves a one-line note on #21120's record, not its own ruling.

(d) Fork three A — right. Write-time copies drop both columns and withhold a quoted hash in the change note and in a decision-audit note. The extended migrateStoredMetadataBodyCopies rewrites copies at rest: dry run by default, --apply writes, idempotent (a rewritten row plans null on the next pass), the history table is never read for rewrite, and the decision-audit table is read by its persisted code in operator form. It stays inside #21144's exception: the same operator-run command, the same two copy tables plus the decision-audit rows those copies came from, no second rewrite path. The CLI keeps #21349's read-only preview boot: the deferSchemaDdl / readOnlyProbe spread for the dry run is unchanged and --apply keeps the plain boot; #21432, now in the base, touches schema-migrate.ts, the preview pin and seven sibling commands, not this command.

(e) Open question 1 — option A was wrong, CI falsified it, and the replacement is right on its four tests. "No provider ⇒ serve no hash" is ruling B's text, but the pre-existing #12181 and #13024 pins hold a landed contract the ruling did not supersede: the version token distinguishes versions and pins a write on the real reset door in a composition that registers no provider. An empty required token kept the ruling's letter and broke that contract, and because the first-party clients never send an empty header it failed open rather than closed; omission would have failed open the same way and would have been a spec edit outside this lane. The replacement: ephemeralStoredHashDigest in metadata-redaction.ts, an HMAC-SHA-256 under 32 random bytes drawn on first use, held in module state, never written, logged or served; storedHashDigest() never answers undefined (the provider's digest, else the process key), and receiptVersion is never empty. (1) Disclosure: the served value is a MAC under a key no caller holds, never the stored hash and never a recomputation from the served body — pinned as neither the stored value, nor hashSpec of the body, nor the test key's digest — so it confirms no guess at withheld material offline, and the three "no provider" sentinels of the dispatched head (empty receipt, null history hash, omitted data-door column) collapse into one served form. (2) Compare: storedParentForToken refuses '' by name and refuses (withheld), a raw hash and a stale token by non-match, with and without a provider, on the save and reset doors — pinned under "a sent token is never read as no pin"; an absent token stays the pinned unpinned write. (3) The #12181 and #13024 files are not in the net diff; the objectql batch-publish conformance pin is blob-identical to main and passes on its original assertion (a non-empty element token with no provider). (4) The arm stays Clause-②: yes (narrowing): with no provider the token moves from the raw stored hash to a keyed value and a held token is refused once, which narrows an accept set and widens none. The premise holds: no stored value persists a served keyed token — the repository stores the canonical hash, the ledger, activity and decision-audit copies drop or withhold every hash at write time and through the migration, the publish door writes a hash-free note, pre-existing notes quote the stored hash (not a served token) and are transformed at read, migrateStoredMetadata passes the stored value in process, the package-publish commit record holds items and seqs only, no cache or validator is built from the token, and the merge brings no write path that stores one. So a restart, or a provider registered later, costs each held token one 409 and nothing else, as the changeset states. Reach is bounded: os serve always wires LocalCryptoProvider and refuses to start in production without a stable key, so the fallback serves embedders, tests and hosts composed without os serve; in such a composition with several processes the tokens are per process. One consistent asymmetry remains and is declared in the changeset and the body: the MCP stdio reader omits the two columns on a host with no provider while the data door serves them under the process key — both are "never raw".

(f) Member 14 — right to close here. It is the same oracle class on the same rows, served by the same doors and copied by the same writer, found by this PR's own after-measurement. Fork one A's purpose is that no known hash exit stays open beside the fix; ruling B's "every exit that serves the hash" covers it without a new ruling. It extends the literal 1 to 13 enumeration by one member, so the card record should carry it.

(g) Engine accessor — right. getKeyedDigest is additive beside setCryptoProvider, read at each use, and hands out the provider's keyedDigest only; no consumer reaches decrypt. The ephemeral fallback lives in the protocol package, not the engine, so the accessor's "undefined while no provider" contract is unchanged. The protocol reads the accessor by duck-typing rather than through a spec contract member, which keeps spec untouched; the engine-double ledger records the doubles that carry it.

(h) Pins — no weakened guard; one pre-existing pin superseded by stacking. The family pin grows from 8 to 23 rows with dispositions seam | keyed | withheld | refuses and a third tooth holding the hash-column list equal to the two object definitions. The two decision-note pins are inverted from "carries the token" to "withheld", which is fork three A. scripts/engine-double-contract.pinned.json gains rows for the three new test files only. The dispatched head had weakened the objectql batch-publish conformance pin (it registered a provider to dodge the empty token); round one restored it byte-for-byte. The #21432 preview pin is the only other pre-existing pin in the net diff, and its change is a stacked update, not a loosening: the title moves from "both tables" to "every audited table", failures from toBe(2) to toBe(audited.length) over a literal three-table set, byObject keys from the two-table literal to that set; exit 1, apply: false and scanned: 0 are kept and nothing is dropped. The #21391 intent (every audited table counted unread) and the #21207 intent (the decision-audit trail is audited) are both stated, and the set is literal so a later widening goes red rather than passing on a stale count. The dev's control removed the decision-audit entry from the runner's table list and the pin went red (1 of 9, the failures assertion), restored blob-equal to HEAD. New pins from round one cover keyed never-empty tokens with no provider, one key per process across two protocol instances, a provider registered later refusing the held token once, and the empty and withheld tokens refused on both doors with and without a provider.

(i) Changeset and PR body. Disclosure discipline held: classes, doors, roles, codes and statuses only, in the body, the changeset and the test titles. The changeset states the three things the ruling requires: one 409 for a held token, 400 for filter, sort and group on the hash columns (and the change note), and the operator runs the extended migration once, dry run first; it also states the ephemeral key, that a token is never empty, the one 409 after a restart or when a provider is first registered, and that an empty, withheld, raw or stale token is never read as "no pin" — every sentence of it holds against the diff; the ADR-0087 marker names the two keys. The PR body was amended by the seat for round one (the acceptance note records that option A was falsified by CI and what replaced it; rows 5 and 8 of the exit table, the superseded-pins bullet and a patch-round paragraph; the opening "Served" and "Inbound" bullets and the member-14 paragraph now state the process key and the MCP exception correctly), and the os-dev-reports 5958622458 and 5960197881 record both rounds with the premise reading, the suites, the control and the gate readings. The round-two edits are on the live body too: the superseded-pins list names the stacked #21432 pin (three tables, each named, none scanned, exit 1, red on the control) and the serial-constraint note records the second merge 8ca49662e8. Every other sentence checked against the diff holds (22 files; rest-server.ts a comment only; runtime/meta.ts and metadata-core/errors.ts untouched; the accessor additive; the history lineage kept; the reset-door pins unedited).

② Semver level

Right. minor for @objectstack/metadata-protocol, objectql, mcp, plugin-audit, service-analytics and cli, a **BREAKING** banner, Clause-②: yes (narrowing), and one ADR-0087 marker not-required (no-migration-prescription) whose reasons hold: no metadata body, authorable key, spelling or export moves, so objectstack migrate meta has nothing to rewrite, and the operator-run rewrite is of copies, not metadata. This is the family's precedent level (PR #21144, PR #21228, PR #21292). @objectstack/rest changes a comment only and correctly carries no changeset. The ephemeral fallback adds no package and moves no level: it is runtime behaviour inside metadata-protocol, and node:crypto was already a top-level import of that package (engines.node >= 22). The merge and the stacked pin change nothing published.

③ Boundary flags

  • The no-provider fallback departs from ruling B's literal sentence ("a deployment with no crypto provider wired serves no hash") in favour of the ruling's security property plus the landed #12181 / #13024 contract that CI enforced. The card should carry one line from the seat or the maintainer acknowledging the departure; the changeset and the body state the behaviour and the round-one report records the falsification and the replacement.
  • The dev's residuals, answered here. (1) A provider-less host run as several processes gives each process its own key, so a token served by one is refused by another: accept for this PR and say so where the fallback is documented — os serve always registers a provider, the cost is a loud 409, never a lost update, and a derived fallback key from a configured secret is a follow-up only if such a deployment is real. (2) The key sits per process in metadata-protocol rather than in the engine accessor: accept — it is the narrower change, every protocol in a process shares it, a host engine without the accessor is covered, and the MCP stdio reader keeps the fail-closed direction (omit) on a host with no provider; moving it into the engine is a follow-up if that reader ever gains a version-token door. (3) Member 14 and the search narrowing: keep both, per (c) and (f).
  • Spec token prose drift: the descriptions of the save, publish and batch-publish tokens in packages/spec/src/api/protocol.zod.ts still say "currently emitted as" an unkeyed hash. The format is declared outside the contract, so it is a docs-only follow-up for the spec seat (with the generated docs regenerated), not a blocker.
  • The hash-column list in four packages: STORED_METADATA_HASH_COLUMNS is spelled in metadata-protocol, mcp, plugin-audit and service-analytics; the family pin holds metadata-protocol's copy equal to the object definitions and the other three are pinned by behaviour. Acceptable now; the natural home is beside the body-column primitives in @objectstack/spec/kernel, a follow-up card for the spec or kernel seat. The same applies to the migration runner's audited-table set, which the stacked pin now states literally because the plugin exports no combined list.
  • Realtime events: answered by the ruling (out of scope; no channel route here; cloud's posture is the cloud seat's).
  • metadata-core base conflict text: answered; no door serves it (above).
  • Cross-lane surfaces: declared before edit — services seat post [PM seat] domain:services · seat 2 — 🟢 os-bill · session_01DiCSbmJrkzNhuEAier4VoJ · R1 #21118 comment 5951545155 (2026-10-02T11:38Z) names plugin-audit and service-analytics; engine seat post [PM seat] domain:engine — 🟢 os-project-manager · session_017ErfyP2Rx7XWHJA27QjyUi #6367 comments 5946329629 and 5951555493 (11:38Z) name metadata-protocol, metadata-core and objectql. The first branch commit 8c610b895d is 12:29Z; both patch rounds stay inside those surfaces (the round-two pin is packages/cli, this lane).
  • Fixes #21207: right. Exit one landed as a Part-of (3ddd3d0c4a), the contract half landed under its own card spec(contracts): ICryptoProvider gains a required keyed digest — the server-held-key HMAC that #21207's served content hash is ruled onto (option B) #21263 (222ecc27f9, closed), and fork one A says one PR closes the ruled family; the Part-of guard passed on every head.
  • Out-of-scope finding, filed: the two engine-only readers outside every enumeration (the automation sandbox's ctx.api.object(...) facade in packages/runtime/src/sandbox/body-runner.ts and the action-handler context's ctx.engine.find in packages/runtime/src/action-execution.ts, neither projecting the body nor keying the hash; the system-object guard at action-execution.ts:2130 covers MCP-invoked actions only) are now [finding] [security] An action/automation body's object API and an action handler's engine handle read the stored-metadata family outside its body projection and keyed serve (reach NOT MEASURED) #21454, a finding under the data-leak exception with reach NOT MEASURED. Nothing in this PR is held on it.
  • A provider that rejects keyedDigest (an embedder's wrong-length explicit key): the receipts throw after a committed write and reads fail. Fail-closed by the dev's design and unreachable from the checked key sources; noted only.

Checks on b53bea8da5, converged 2026-10-02T20:19:36Z, latest run per name, 34 names, 0 pending, none failed: 29 success — Lint & Repo Gates, TypeScript Type Check, Type Check · workspace, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Build Core, Test Core and shards (1/6) to (6/6), Dogfood Regression Gate and shards (1/3) to (3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Check Changeset, Check Documentation Links, Flag docs affected by code changes, filter, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch; 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)). mergeable_state reads clean; no governed path is in the file list. The superseded heads converged red and carry no record: 1ad5a0099e on Test Core (1/6), (5/6) and the rollup (the two reset-door pins), 7660d811a7 on Test Core (5/6) and the rollup (the #21432 pin), every other required context success on both.

Implemented-by: claude/issue-21207-exit-two-keyed-served-hash
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 20:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 20:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 713b0fa Oct 2, 2026
43 checks passed
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

2 participants