Skip to content

fix(metadata-core,metadata-fs): hash the serialized form, so put().version identifies the bytes actually stored (#7856) - #7992

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-7856-hashspec-serialized-form
Aug 12, 2026
Merged

fix(metadata-core,metadata-fs): hash the serialized form, so put().version identifies the bytes actually stored (#7856)#7992
huangyiirene merged 2 commits into
mainfrom
claude/issue-7856-hashspec-serialized-form

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7856

⚠️PR opened by the domain:metadata PM seat on the implementing dev's behalf. The dev pushed claude/issue-7856-hashspec-serialized-form but had no GitHub write access in its container (three of four sibling dispatches hit the same thing this shift — branches pushed, no PRs). Body below is assembled from the dev's own changeset; the dev's full report will be appended as a comment when it hands the text over. Flag any gap to me, not to them.

Implements the triage seat's ruling of 2026-08-12 (comment 5263973459): option 2 — hash the serialized form — plus the head-index fix and a table-driven pin. Option 3 (document the incoherence) was rejected there; option 1 (reject non-round-tripping specs at the door) was deferred to its own card, not ridden here.

The defect

hashSpec canonicalised a Date to {}, because canonicalize walked a value's own enumerable keys and a Date has none. JSON.stringify — what every repository actually writes — turns the same Date into an ISO string. So the hash of the in-memory spec and the hash of the bytes on disk were different hashes for the same item, and the version handed back to a caller did not identify what had been stored.

Measured on main, one spec carrying one Date:

canonicalize(in-memory) : {"createdAt":{},"label":"Home"}
JSON.stringify (bytes) : {"label":"Home","createdAt":"2024-01-01T00:00:00.000Z"}

The change

canonicalize now honours toJSON exactly as JSON.stringify does — consulted once per position, its result serialised as-is and never re-consulted — which makes a new guarantee true by construction:

canonicalize(x) === canonicalize(JSON.parse(JSON.stringify(x)))

⭐ Both repository implementations were wrong, in different places

This is why the fix is one function rather than two patches:

  • FileSystemRepository broke put().version === get().hash — it hashed the spec it was handed, wrote JSON.stringify of it, then re-hashed the parse on the way back out.
  • InMemoryRepository broke the repository contract's invariant 4 (item.hash === hashSpec(item.body)) — it stores body already serialised (clonePlain) while hashing the in-memory spec, so the item it returned disagreed with its own hash.
  • SysMetadataRepository inherits the fix through the same function.

Downstream, an incoherent version meant a repository could report an {op:'update', actor:'fs'} for a file nothing outside the process had touched: the head index held a hash the disk could never reproduce, so re-reading one's own write looked like somebody else's edit. That surfaces without any watcher — a restart rebuilds the index from disk and the version the caller was handed no longer matches it.

⭐ Ordinary specs hash exactly as before — this is not a migration

The PM brief made this a hard criterion: if ordinary specs churn, it is a migration, not a fix. The dev measured it at corpus scale rather than asserting it.

Verified against this repository's entire checked-in JSON corpus — 1973 files hashed under both the old and the new implementation, 0 hashes changed — and the hashSpec({}) regression guard in metadata-core is unmoved.

The new path diverges only at a position carrying a callable toJSON; a graph without one is byte-identical through canonicalize. Stored versions for ordinary specs keep their meaning. Versions for toJSON-carrying specs do change — and those are exactly the versions that never identified their stored bytes in the first place.

Also supported as a consequence: a class instance with a toJSON now hashes as whatever it serialises to, rather than as its private fields. One without a toJSON still hashes as its own enumerable keys — which is what JSON.stringify writes for it.

The pin

Table-driven, as the card demanded (⛔ not a single hand-picked case), and placed in the shared repository contract suite so everyMetadataRepository implementation is held to it — a stronger placement than the brief asked for. Rows: Date at a key, Date under an array index, a class whose toJSON yields a string, an object literal carrying its own toJSON, a nested case, and a plain-JSON control row that proves the fix did not simply change every hash.


Generated by Claude Code

…sion identifies stored bytes (#7856)
`canonicalize` walked a value's own enumerable keys, so a `Date` canonicalised
to `{}` while `JSON.stringify` — what every repository actually writes — turned
it into an ISO string. The hash of the in-memory spec and the hash of the bytes
on disk were therefore different hashes for the same item.
`normalise` now honours `toJSON` exactly as `JSON.stringify` does: consulted
once per position, its result serialised as-is and never re-consulted. That
makes canonicalize(x) === canonicalize(JSON.parse(JSON.stringify(x))) true by
construction, which is what restores `put().version === get().hash`.
Both repository implementations were wrong in different places, so the fix is
one function rather than two patches: FileSystemRepository broke
put().version === get().hash, InMemoryRepository broke the contract suite's
invariant 4 (item.hash === hashSpec(item.body)) because it stores `body`
already serialised while hashing the in-memory spec.
Ordinary specs are unaffected: the new path diverges only at a position
carrying a callable `toJSON`. Verified over the repository's whole checked-in
JSON corpus (1973 files hashed both ways, 0 hashes changed).
The pin is table-driven and lives in the shared repository contract suite, so
every MetadataRepository implementation is held to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1j5QbRXy7iHasccZkx55y
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 9:48am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-core.

2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-core)
  • content/docs/plugins/packages.mdx(via @objectstack/metadata-core)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v12.mdx(via @objectstack/metadata-core)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 10:21
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit c7e7900Aug 12, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7856-hashspec-serialized-form branch August 12, 2026 10:39
@huangyiirene
huangyiirene restored the claude/issue-7856-hashspec-serialized-form branch August 12, 2026 11:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude