Skip to content

Reject specs that do not round-trip through JSON at put() — the option-1 tightening deferred by #7856 #8006

Description

@huangyiirene

Filed by the domain:metadata PM seat. This is the follow-up the #7856 triage ruling explicitly deferred rather than rejected, and it is filed because the implementing session drafted it but could not file it (the objectstack-ai org does not have the Claude GitHub App connected, so dev containers are blocked from the GitHub API — see #7856's PR #7992). Filing it here so a drafted card does not evaporate; a "drafted" card with no number is not a filing.

Provenance and what is already settled

#7856 (landed as PR #7992, c7e7900) fixed the coherence half: canonicalize now honours toJSON exactly as JSON.stringify does, so canonicalize(x) === canonicalize(JSON.parse(JSON.stringify(x))) holds by construction and put().version === get().hash is true again.

The triage ruling on #7856 (comment 5263973459) chose option 2 (hash the serialized form) and was explicit about this card:

Option 1 (reject non-round-tripping specs at the door) is NOT rejected on the merits — it is a separate accept-set tightening with its own blast radius; if the dev's measurement of real spec corpora shows non-round-tripping values are rare-to-zero, file the option-1 tightening as its own follow-up card (contract-first, likely domain:spec-adjacent for the authorable face) rather than riding it here.

That measurement was taken and it favours the tightening.#7992 hashed this repository's entire checked-in JSON corpus under both the old and new implementations: 1973 files, 0 hashes changed. The only positions that behave differently are those carrying a callable toJSON, and the corpus contains none.

The question this card decides

put() currently accepts a spec whose in-memory form does not round-trip through JSON. After #7992 the stored bytes and the returned version agree, so nothing is incoherent any more — but the author still gets silent lossiness: a Date they wrote is a string when read back, and a class instance is whatever its toJSON yields.

Should put()refuse such a spec, loudly, naming the offending key?

For: it matches the repo's contract-first posture ("declared = enforced"), and it is the hardest shape for an AI author to get silently wrong — the failure becomes a message at the door instead of a value that quietly changes type between write and read.

Against: it is an accept-set tightening, i.e. a breaking change for any caller that does this today. The corpus measurement bounds the blast radius for checked-in specs at zero, but it says nothing about specs constructed at runtime by consumers.

⚠️Measure the runtime constructors before assuming zero. The 1973-file result covers the on-disk corpus only. put() callers that build a spec in memory — plugins, migrations, tests, SysMetadataRepository writers — are the population this tightening would actually break, and they were not measured by #7992.

⚠️ Three open questions the drafting session flagged

Recorded as questions, not answers — this card does not pick:

  1. Where does the refusal liveput() in each repository implementation, or the shared contract suite / hashSpec layer that fix(metadata-core,metadata-fs): hash the serialized form, so put().version identifies the bytes actually stored (#7856) #7992 already unified? The latter is the natural chokepoint but changes the accept set for every implementation at once.
  2. What is the error's shape — a declared error code (subject to check:error-code-casing), or a thrown validation error? It must name the offending key path, not just the fact of the failure.
  3. Is Date genuinely disallowed, or normalised? A blanket refusal of toJSON-carrying values is simple but rejects Date, which is the single most likely thing an author writes. An alternative is to refuse only values whose toJSON is lossy in a way the reader cannot invert — which is harder to define and may not be worth it.

⚠️If the choice turns on product intent unreadable from the code, that is a needs-user-decision, not a coin flip — the original card said so and it still applies.

Routing note

Filed domain:metadata because the mechanism lives in packages/metadata-core / packages/metadata-fs. ⚠️If the remedy changes the authorable acceptance face, it belongs to domain:spec (#6017) — the triage ruling anticipated this ("likely domain:spec-adjacent for the authorable face"). Re-route rather than reaching across.

Precondition

⛔ Do not start before confirming #7992 (c7e7900) is on main — this card is meaningless without it, since the coherence bug would otherwise mask the accept-set question.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions