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

Draft
objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-21207-exit-two-keyed-served-hash
Draft

objectstack-fleet[bot] wants to merge 11 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 the crypto provider's keyed digest of it, or nothing when no provider is registered.
  • 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, every token is refused.
  • 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, or (withheld) 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.

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.

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 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65.

⛔ 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 — 62 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 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b3add2717aa954cae8612d1c13936327a3491d23 — the merge of head 7660d811a742201a77de376488741837a8aed310 into base 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65, 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 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

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

…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>

This branch has not been deployed

No deployments
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