fix(metadata-protocol)!: a metadata body's stored content hash is served and compared only in keyed form, never copied, never evaluated (#21207) - #21436
Conversation
…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>
…ssue-21207-exit-two-keyed-served-hash
…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>
📓 Docs Drift CheckThis PR changes 7 package(s): 38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 62 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b3add2717aa954cae8612d1c13936327a3491d23 && git checkout b3add2717aa954cae8612d1c13936327a3491d23
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 7660d811a742201a77de376488741837a8aed310 && git checkout -B drift-repro 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 && git merge --no-ff 7660d811a742201a77de376488741837a8aed310
node scripts/docs-audit/affected-docs.mjs --json 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65
|
…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>
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 report5946577002(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:
409 METADATA_CONFLICT. With no provider, every token is refused.400 INVALID_FIELDbefore 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.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
/metasave door, runtime dispatcher save door/metapublish door/metarollback door/metahistory door/metaaudit door and the data door(withheld)ornull400 INVALID_FIELD, naming the usable columns400 INVALID_FIELDin either roleMember 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:
(withheld)with no provider.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 handeddecrypt.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 servedoes. Before is baseecb6ca0258; after is this branch.METADATA_CONFLICTINVALID_FIELDon eachINVALID_FIELD/metaaudit door, data door)INVALID_FIELDINVALID_FIELDMember, before and after alike: data door 403
PERMISSION_DENIED, history door 403, ledger 403, analytics 403PERMISSION_DENIED, and MCPPERMISSION_DENIEDon every member.Copies at rest, measured through the CLI door on a database the base code wrote:
--apply --yes(exit 0)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.
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:
typecheckexited 0 for metadata-protocol, objectql, mcp, plugin-audit, service-analytics, rest and cli.Superseded pins updated:
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 emptygit diff HEAD.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 --commandsderived 84 commands. All 84 ran, every exit code recorded, all 0.--ranreconciles 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN.check:error-code-casingandcheck:nul-bytesexited 0.pnpm lint(the whole repository) exited 0.Gate hygiene this needed:
check-engine-double-contract --write;Acceptance notes
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.41a3c8df15). It adds no hash exit.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