Skip to content

fix(metadata-core): a reorder of an object's fields is a content change, so the designer's publish is no longer dropped (#21790) - #21814

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21790-ordered-fields-hash
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21790-ordered-fields-hash

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21790
Clause-②: yes

What was wrong

ObjectSchema.fields is a name-keyed map, and the spec declares its traversal order to be the field order. canonicalize sorted every map, so a pure reorder of fields hashed equal to the published row. SysMetadataRepository.put treats an equal hash as a no-op and skipped the write. promoteDraft then drained the draft anyway. So the publish answered success, the served object kept the old order, and the draft was gone. That is the card's reproduction. Under the reverse verification below, the new dogfood pin reproduces it: it reads title, amount, due back after publishing due, title, amount.

The fix

  • @objectstack/metadata-core: canonicalize(value, type?) and hashSpec(value, type?) take the metadata type. A per-type table names the maps whose key order is content. Today it has one row, object maps to fields, and the table's docblock cites the spec text behind it. That map keeps its insertion order in the canonical form. Everything else stays key-order independent: the keys around fields, the keys inside each field definition, and every map of every other type. Without a type, the output is byte-identical to before. The table is exported read-only as orderedMapKeys(type?). InMemoryRepository and the shared contract suite hash as ref.type, and invariant 4 is restated as item.hash === hashSpec(item.body, item.ref.type).
  • @objectstack/metadata-protocol: SysMetadataRepository hashes as ref.type (in put, and in the rowToItem fallback for a null checksum). For a type with an ordered map, its no-op short-circuit compares content: it re-hashes the stored body under the current rule rather than trusting the stored stamp. A skipped write returns the stored stamp, which is the version every reader already holds (see H4).
  • @objectstack/metadata-fs: FileSystemRepository hashes as ref.type at all four sites (get, put, boot scan, watcher).

Landing: the dispatch named canonicalize.ts and the hashSpec call site, and measurement kept it there. The type has to reach canonicalize, so the two other repository implementations that call hashSpec (InMemoryRepository, FileSystemRepository) pass it too. Otherwise the shared repository contract would hold two hash rules. No packages/spec/src/** edit: the order is already declared in spec prose (H2), and the table implements that declaration.

@objectstack/metadata-core is graded minor: two exports widen (a new optional parameter and a new function). That follows the "WHICH LEVEL" rule in pr-automation.yml, under which a fix that widens an index is minor. Because the diff widens that published surface, the declaration is Clause-②: yes. The claim predicted no before the diff existed, and the seat's review corrected it. No payload key, accepted value or error code moves. metadata-protocol and metadata-fs are patch.

H1: every place a content hash or stored checksum decides "unchanged" (at 18c2ddc1ec)

site compares what equality drops
metadata-protocol/src/sys-metadata-repository.ts:620 (put) stored checksum vs hashSpec(incoming) the row write, the history row and the watch event; it re-yields the stored item. The defect site.
sys-metadata-repository.ts:886 / :972 (promoteDraft) delegates to put above with parentVersion = the stored active stamp the publish (through :620); the draft drain still runs, which is why "the draft is gone"
sys-metadata-repository.ts:613 / :786 (put / delete lock) caller parentVersion vs stored checksum nothing; inequality throws ConflictError (a lock, not a no-op)
sys-metadata-repository.ts:1945 (rowToItem) none; re-derives only when checksum is null n/a (the read returns the stored stamp)
metadata-protocol/src/protocol.ts:16901 (storedParentForToken) caller If-Match token vs keyed digest of the stored head nothing; inequality answers 409
protocol.ts:14082 (getMetaItem ETag) simpleHash of the served JSON (order-sensitive already) vs If-None-Match the body (304); not a write decision
metadata-core/src/in-memory-repository.ts:107 (put) current head hash vs hashSpec(incoming) the write and the event
metadata-fs/src/repository.ts:362 (put) / :1006 (watcher) head index (re-derived at boot) vs hashSpec(body) the file write / the external-change event
metadata/src/loaders/database-loader.ts:1414 (save) / :728 (history) its own key-sorted calculateChecksum, a separate vocabulary the write / the history row. Reached only through MetadataManager.register / save; the Studio save and publish path does not call them (no caller of MetadataManager.register in packages/*/src). Unchanged; see Acceptance notes.
objectql src/ has no comparison. scripts/dry-run-hash-compat.ts compares stored checksum with type-blind hashSpec(body) in an offline audit probe nothing; it reports. Unchanged; see Acceptance notes.

H2: maps a stored metadata body declares ordered

map declared ordered? in the table
object.fields yes. ObjectFieldGroupSchema (data/object.zod.ts ~1080-1084): "the in-group display order equals the traversal order of ObjectSchema.fields", and ungrouped fields keep "their field declaration order". fieldGroups (~2090-2093): fields are displayed "in their ObjectSchema.fields declaration order". The record field type it is authored through (data/field.zod.ts:90, object.form.ts:114): "Insertion order = display order". data/display-name.ts:50: declaration order is Object.keys order, and the first title-eligible field "by declaration order" is picked yes
object.listViews no order statement (rendered as tabs, keyed by view name) no
ObjectExtension.fields no statement, and not a stored metadata type (code-only defineObjectExtension) no
nested fields records (security/permission.zod.ts:877, system/translation.zod.ts:96/206, flow node configs) no statement no
inline locale maps (I18nLabel) the resolver breaks ties by key insertion order (ui/i18n-label-resolver.ts:44, limbs 3 and 6, mirroring objectui's pickLocalized). That is a tie-break rule, not a declared authoring order, and these maps sit at arbitrary depths no (raised in the report)
AnalyticsQuery.order / DatasetSelection.order declared ("applied by key in insertion order"), but these are request bodies, not stored metadata. Reports store order as an array because "JSON object key order is not a contract" (ui/report.zod.ts:48) n/a

The only row is object.fields, which the spec already declares. No new spec declaration was needed.

H3: where the order is kept

canonicalize takes the metadata type. That is the least surface that keeps one hash rule. A pre-pass in hashSpec's caller would leave hashSpec(body) disagreeing with the repository's hash, which breaks contract invariant 4, and every repository implementation would need its own copy. The type is in hand at every call site (ref.type), and an omitted type is exactly the old rule. Guarantees 5 (idempotence) and 7 (serialized-form identity, #7856) hold for an ordered map because JSON.stringify writes own keys in Object.keys order and JSON.parse recreates them in that order. Both are pinned as properties under the object type.

H4: stored hashes after the change (measured)

  • Does a stored checksum stop matching the recomputed hash? Yes, for an object whose fields are not in sorted key order. Objects with sorted fields hash byte-identically (pinned).
  • Read / ETag / If-Match / conflict: unaffected. rowToItem returns the stored stamp. The ETag hashes the served document, not the checksum. If-Match tokens are keyed digests of the stored stamp, and the repository lock compares against the stored stamp. Nothing re-derives a stamp to judge a lock, so upgrading raises no conflict. Pinned end to end: a receipt token taken over a row with an old stamp is honoured by If-Match.
  • Misfire 1 (handled): with only the type-aware hash, an identical re-save of an old row would compare current hash against old stamp, see a difference, and write a history row recording no change. storedBodyUnchanged re-hashes the stored bytes under the current rule. The save is a no-op, and the stamp stays as written.
  • Misfire 2 (handled): a reorder into sorted key order hashes, under the current rule, to exactly the old order-blind stamp. A stamp comparison therefore drops it: the original defect, surviving on exactly the rows stored before the fix. The content comparison writes it.
  • Boot re-derivation: SysMetadataRepository re-derives nothing. FileSystemRepository re-derives its head index at boot under the new rule, and the watcher and resync compare new against new, so there are no spurious events. Its JSONL log keeps pre-change hashes, so provenance on get / list for such an object falls back to defaults until the item next changes. That is measured but not handled; see Acceptance notes.

H5: the negative

Pinned: swapping the keys around fields, the keys inside a field definition, and a map inside a field definition all hash equal under object. A property test holds every non-fields key order-independent. A fields map under a type with no row (view), and under no type, hashes equal across a reorder.

Pins

  1. Dogfood (packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts, showcase, real HTTP). It creates and publishes an object, saves a reorder as a draft, publishes it, and reads the new order back through GET /api/v1/meta/object/:name twice (the designer reload), plus the stored row. Then it rewinds the row to the order-blind stamp. An identical save records no history row and keeps the stamp. That save's receipt token is honoured by If-Match, and a reorder into sorted order is published.
  2. Hash (packages/metadata-core/test/canonicalize.test.ts): the order half and the unordered half (H5), side by side.
  3. Guarantees 5 and 7 with the ordered map: properties under object, plus toJSON inside fields and on fields itself.
  4. H4 at the repository (packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts, on that file's existing engine double; no new double). The draft-and-publish reorder through promoteDraft. Over an old-stamp row: read, identical re-save, draft-and-publish reorder (previous_checksum = the old stamp), and the reorder into sorted order.

Reverse verification (both run from committed state; every mutation through scripts/ablation-replace.mjs, dist/ proven with scripts/ablation-dist-preflight.mjs)

A. Order-blind fields restored (the object row deleted from the table). Pre-mutation dist/ reading: marker Object.freeze(["fields"]) present in 2 files. Mutate: anchor 1 to 0, blob c2483ce615dd to 169e16f653bf. metadata-core rebuilt, preflight --absent green.

  • pin 2: 5 red / 38 green. Red: the table row, "hashes a reordered fields map differently", "keeps the declared field order", "a reorder INTO sorted order is still a change", and the toJSON-on-fields case (it asserts the kept order). Green: every H5 case, both byte-identical cases, and both guarantee properties.
  • pin 1: 3 red / 2 green. The reorder publish reads back title, amount, due, the card's defect. The stale-stamp precondition and the sorted-order publish are also red. The declared-order create and the identical save stay green.
  • pin 4: 5 red / 50 green (every order-dependent case).
  • Restore: blob back to c2483ce615dd, git diff HEAD empty, rebuilt, marker present again, tree clean.

B. Content comparison reverted to a stamp comparison (storedBodyUnchanged), with the type-aware hash kept. Mutate: anchor 1 to 0, plant 0 to 1, blob 013ffb59ed3d to 195cf07493ae.

  • pin 4: 2 red / 53 green. Exactly "an identical re-save: no conflict, no history row" and "a reorder INTO sorted order is written".
  • pin 1, after metadata-protocol was rebuilt: 1 red / 4 green ("an identical save records no change"). The type-check of the deliberately ill-typed mutation failed the d.ts step after the JS was emitted, and preflight proved the marker in 2 built JS files.
  • Restore: blob back to 013ffb59ed3d, git diff HEAD empty, rebuilt, marker absent, tree clean.

Local verification

All readings were taken at 071959e91e (this branch merged with origin/main 75ddcd1b41) on a shared 4-core box. Lock seconds are shared-box figures.

  • Build: turbo run build --filter=@objectstack/dogfood^... plus the eight packages check:dual-build-cjs-loads reads: 71/71 tasks.
  • @objectstack/metadata-core: test 389/389 (18 files); typecheck exit 0.
  • @objectstack/metadata-fs: test 70/70 (10 files); typecheck exit 0.
  • @objectstack/metadata-protocol: typecheck exit 0, and the pin file 55/55. The full suite ran before the merge, at bc6102dddb: 19959 passed, 19 skipped (213 files passed, 3 skipped). Of this PR's packages, the merge touched only metadata-core/src/protocol-handshake.ts, a file this diff does not touch.
  • @objectstack/dogfood: the new file 5/5; typecheck exit 0. The rest of the dogfood suite is CI's.
  • @objectstack/objectql src/dry-run-hash-compat.test.ts, the one other hashSpec caller: 17/17.
  • ESLint, narrowed to the 10 changed .ts files: eslint --no-inline-config --format json read 10 files and reported 0 errors, 0 warnings and none ignored. Population: the config's **/*.{ts,…} blocks, which contain all 10. Invariance: the config enables no type-aware linting (no parserOptions.project), so this diff cannot move a verdict on any untouched file.
  • Gates: dispatch-gates --commands, re-derived at this head, gave 67 commands. Its artifact-roster block added 54 and the four symbol-anchor sweeps 4: 124 run, 121 exit 0. The --ran reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. The 3 non-zero exits are exit 2, from gates that read pull-request context and judged nothing locally: check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths. Re-run with this body as PR_BODY, check-partof-closing-keyword is green. In that run, check-changeset-no-major --event (over the body as it then read) found no major and read Clause-②: no. See the re-run below for the corrected declaration. check:engine-double-contract: OK, no new double.
  • Re-run at 6fe110968c, after the declaration was corrected to Clause-②: yes in this body and all three changesets, and origin/main 18c7dfd2e6 was merged (docs/qa only): check-changeset-no-major --base origin/main --event over this body exits 0. It finds no major, and its level axis reads that the PR declares clause-② yes and grades @objectstack/metadata-core minor; metadata-fs and metadata-protocol are patch and not refused. check-adr-0087-registration exits 0, reporting no declared-breaking changeset. check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors and check:adr-anchors all exit 0. The metadata-core, metadata-protocol and metadata-fs typechecks all exit 0. No source changed between 071959e91e and this head, so the test readings above stand.
  • NOT MEASURED here (CI's): pnpm lint over the repo, the workspace type-check lane, the Test Core shards, the rest of the Dogfood Regression Gate, and Temporal Conformance.

Acceptance notes

  • Not changed, observed: DatabaseLoader (packages/metadata) keeps its own key-sorted calculateChecksum and skips a save on equality. It is a second hash vocabulary over the same sys_metadata table. It is reached only through MetadataManager.register / save, which no Studio save or publish path calls (no caller in packages/*/src). Carrier: none.
  • Not changed, observed: the offline probe packages/objectql/scripts/dry-run-hash-compat.ts (PR-10d.1) hashes type-blind. On an object row stamped by the new rule with unsorted fields, its checksum_drift arm fires. It is not published (files is dist only) and has no runner beyond its own test. Carrier: none.
  • Measured, not handled: FileSystemRepository's JSONL log keeps pre-change hashes. For an object with unsorted fields, provenance on get / list falls back to defaults: no parent hash, seq 0, the filesystem actor and the epoch timestamp. This lasts until the item next changes. Production consumer census: MetadataManager.getRepository() has no caller in packages/*/src, and the manager consumes only watch() events, which are unaffected. Stated in the metadata-fs changeset.
  • Residue, filed as [finding] revertCommit's restore limb hands a stored manifest-plural type ('objects') to put, so a legacy plural row's field-order restore is skipped as unchanged and still reported restored #21821: revertCommit's restore limb passes the commit item's stored type to put unfolded, so a legacy row stored under a manifest plural such as objects reaches the repository under that spelling. The ordered-fields rule applies only to the canonical object spelling. A reorder-only restore of such a row is therefore skipped as unchanged. Only rows written before the meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432 boundary fold are affected; every /meta door folds today.
  • FileSystemRepository takes the metadata type from the directory name verbatim (parseItemPath, no validation). An objects/ directory would hash order-blind. Its put has no production caller; it is attached only as an event source.
  • Carried over from this thread. The seat's REWORK (comment 5988361846): the declaration is corrected to Clause-②: yes here and in the three changesets, and the plural-spelling measurement it asked for is answered in the os-dev-report on metadata: publishing a field reorder from the object designer is a silent no-op — the content hash is key-order-insensitive, so a reordered fields map hashes equal and the draft is dropped #21790. The docs drift check (comment 5988314939): content/docs/concepts/metadata-lifecycle.mdx:149 says the hash is a sorted-keys serialization of the body. After this PR that is true except for object.fields, whose order is kept. Done at 046c425d62: that paragraph now says the canonical form sorts keys except in the maps the spec declares ordered (today an object's fields), that put() still short-circuits on an unchanged body, and that a row written before this release keeps its stored hash as its version token until its content next changes. dispatch-gates --commands for that path derives 41 gates. All 41 exit 0, check:doc-authoring included, and --ran reconciles 41 derived, 41 run, 0 NOT-MEASURED. check:skill-examples first answered PREREQUISITE NOT MET because client-react was unbuilt; it passed once that package was built. origin/main moved to ba57588665 (service-settings and the spec crypto-provider contract) after the pre-push check, so this head does not merge it.
  • A reorder into sorted order over an old-stamp row is written with checksum equal to its previous_checksum: the two hashes are taken under different rules. getByHash on that value can find either history row. Objects are not executionPinned, so no pinned execution resolves through it.

Generated by Claude Code

claude added 4 commits October 5, 2026 03:38
The canonical hash sorted every map, so a pure reorder of
`ObjectSchema.fields` -- whose traversal order the spec declares to be the
display order -- hashed equal, the repository's no-op short-circuit read the
publish as unchanged, and the object designer's reorder was dropped while the
publish answered success.

`canonicalize` / `hashSpec` take the metadata type; a per-type table names
the maps whose key order is content (today `object.fields`). Every other map,
and every key inside a field definition, stays key-order independent, and a
call without a type is byte-identical to before. Each repository hashes as
`ref.type`.

`SysMetadataRepository.put` decides "unchanged" for a type with an ordered
map by re-hashing the stored body under the current rule, so a row stamped
before this change neither writes a no-change history row on an identical
re-save nor drops a reorder into sorted order; a skipped write hands back
the stored stamp, the version token readers already hold.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…s stored before it

- dogfood: an object-designer reorder saves as a draft, publishes and reads
  back in the new order through `GET /meta/object/:name`; then, over a row
  rewound to the order-blind stamp, an identical save records no change and
  keeps the stamp, its receipt token is honoured by If-Match, and a reorder
  into sorted order -- whose hash IS the stale stamp -- is published.
- SysMetadataRepository: the same two halves at the repository, through
  `promoteDraft`, on the contract file's engine double.
- changesets for the three moved packages.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…sert the publish row by kind

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

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/metadata-fs, @objectstack/metadata-protocol, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-core/src/repository.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via FileSystemRepository (symbol, a top-level class), SysMetadataRepository (symbol, a top-level class))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-core/src/repository.ts) — pages documenting those are invisible to this run
  • 4 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 — 13 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 ba57588665fd846eb95b682b3660f52a697370a9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json ba57588665fd846eb95b682b3660f52a697370a9

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

REWORK (seat review, narrow) — PR #21814 at head 071959e91e

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T04:54Z. The os-dev report is on #21790 (5988341325). Judged against GitHub and the branch, not against the report.

The fix itself reads right, and nothing in the mechanism is asked to change.

  • canonicalize(value, type?) and hashSpec(value, type?) keep object.fields' insertion order through a one-row table. Every other map, and every key inside a field definition, stays key-order independent. A call with no type is byte-identical to before.
  • SysMetadataRepository.put's no-op check re-hashes the stored body under the current rule, only for a type with an ordered map. A skipped write returns the stored stamp, so upgrading raises no 409 (H4).
  • The census answers H1 and H2. The two reverse-verification legs went red exactly on the order half and on the stamp comparison.
  • Both open questions are answered A (accept the FS provenance default as recorded; locale maps stay out), on the reasons the report gives. Both are already stated in the changesets and the PR body.

One defect: the Clause-② line.

  • The clause asks one question (scripts/pm/clause2-line.mjs): does this card relax an accept set or widen the public surface?
  • This diff widens @objectstack/metadata-core's published surface. orderedMapKeys is a new export through index.ts (export * from './canonicalize.js'), and canonicalize and hashSpec take a new parameter. The metadata-core changeset says so itself: "two exports widen".
  • So the line is Clause-②: yes, not no. The seat's claim predicted no before the diff existed; the PR states what the diff does.
  • Asked:
    1. Line 2 of the PR body becomes Clause-②: yes.
    2. The changesets' Clause-②: lines agree with it.
    3. Each moved package carries the level check-changeset-no-major requires under yes, measured with the corrected body.
  • Asked, one measurement: state that every caller of hashSpec(…, ref.type) in the three repositories receives the canonical singular type. orderedMapKeys('objects') is [], so a plural spelling would silently hash order-blind. Name where the spelling is folded before put.
  • Then: with Clause-②: yes, a contract review at CONTRACT_REVIEW_TIER is owed before landing. The seat runs it on the corrected head.

Recorded, not filed, as the report states: the DatabaseLoader second checksum vocabulary (no caller in packages/*/src), and dry-run-hash-compat.ts's checksum_drift arm firing on new stamps (an offline probe).


Generated by Claude Code

claude added 3 commits October 5, 2026 05:02
…s a published surface

orderedMapKeys is a new export and canonicalize/hashSpec take a new
parameter, so the PR widens @objectstack/metadata-core's public surface.
Levels unchanged: metadata-core minor, metadata-protocol and metadata-fs
patch; the level axis accepts them under yes.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
The canonical form sorts keys except in the maps the spec declares
ordered (today an object's fields), put() still short-circuits on an
unchanged body, and a row written before this release keeps its stored
hash as its version token until its content next changes.

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

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21814 at head 046c425d62 · contract review owed before landing

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T05:53Z. This follows the seat's narrow REWORK 5988361846 and two patch rounds. The dev's follow-ups are on #21790.

  • REWORK resolved:

    • Line 2 of the PR body and all three changesets read Clause-②: yes. orderedMapKeys is a new export of @objectstack/metadata-core, and canonicalize/hashSpec take a new parameter, so the public surface widens.
    • Levels: metadata-core minor, metadata-protocol and metadata-fs patch. check-changeset-no-major over the corrected body exits 0, and its level axis reads yes with metadata-core at minor. check-adr-0087-registration exits 0.
  • The type-folding measurement:

  • Docs (patch round 2): content/docs/concepts/metadata-lifecycle.mdx's hash paragraph now says what the code does:

    • keys sort, except in the maps the spec declares ordered (today object.fields);
    • put() short-circuits on an unchanged body;
    • a row written before this release keeps its stored hash until its content next changes.

    All 41 gates derived for that path exit 0, check:doc-authoring included.

  • Mechanism, unchanged since the review at 071959e91e:

    • object.fields keeps its insertion order in the canonical form.
    • Every other map, and every key inside a field definition, stays key-order independent. A call with no type is byte-identical to before.
    • SysMetadataRepository.put re-hashes the stored body under the current rule for an ordered type, and a skipped write returns the stored stamp. So no spurious 409 follows an upgrade.
  • Both open questions: answered A in the REWORK (the FS provenance default is recorded; locale maps stay out).

  • CI on 046c425d62: 42 check runs, 38 success, 4 skipped, 0 failed. The failed type-check and Test Core runs earlier in the timeline were on the superseded heads 071959e91e and 6fe110968c, cancelled by the newer push.

  • Labels: needs:contract-review is hung on this PR in this act. Clause-②: yes makes the review owed. The seat runs it at its tier on this head, posts the record, and only then lands.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 046c425d62db1c00fade07cde87f632c1bd7f899
Local-runs: none

Inputs: card #21790 (body, comments 5987334318, 5987515805, 5988341325, 5988409457, 5988464990, 5988540414), PR #21814 (body, 14-file list, net diff against the merge-base 18c7dfd2e6, which is the PR's base sha), and the 46 check-runs on the head. Line numbers below are at the head unless marked main. Every seat conclusion on the thread was set aside, as the brief asks.

Gate verdicts on the head: every completed check-run is success or skipped; none failed. Check Changeset ran three times on this head (111625285701, 111625466899, and 111634383656 after the label write) and all three are success. Type Check (workspace, source gates, consumer gates, debt ledger), Lint and Repo Gates, Build Core, Build Docs, Test Core 1 to 6, Dogfood Regression Gate 1 to 3, Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Governed Surface Queue Guard, and the four claim and part-of gates are all success. The Docs Drift Check (PR comment 5988314939) states CI read the merge of this head into ba57588665, so the gates judged the head on a newer main than the head itself merges.

① Derived judgments

Public surface of @objectstack/metadata-core (all reached through packages/metadata-core/src/index.ts:11, export * from './canonicalize.js'):

  • canonicalize(value, type?) takes a new optional parameter (canonicalize.ts:131). Widening. Declared in the changeset (.changeset/21790-metadata-core-ordered-fields-hash.md:9). Right.
  • hashSpec(value, type?) takes the same parameter (canonicalize.ts:226). Widening. Declared (same changeset, line 9). Right.
  • orderedMapKeys(type?) is a new export (canonicalize.ts:120-122). Widening. Declared (line 10). Right.
  • Omitting type is the old rule byte for byte: orderedMapKeys(undefined) answers the frozen empty list (canonicalize.ts:83, :120-122), so normalise sorts every map as before (:206). Pinned at test/canonicalize.test.ts ("with no type, a reorder of fields hashes equal" and the empty-spec fixture). Right.
  • The table has one row, object to fields (canonicalize.ts:110-111), and is a Map, so orderedMapKeys('constructor') is empty rather than an inherited function (:107-108, pinned). Right.
  • The row is grounded in spec prose, not renderer behaviour: packages/spec/src/data/object.zod.ts:1080-1084 (main, "the in-group display order equals the traversal order of ObjectSchema.fields", ungrouped fields "preserving their field declaration order"), :2090-2093 (main), field.zod.ts:90 (main, "Insertion order = display order"), display-name.ts:50-52 (main). The triage direction 5987334318 ("no explicit order key") is honoured: no key is added. Right.
  • Only the root position receives orderedChildren; every deeper position receives none (canonicalize.ts:159-163, :195, :208). So the keys around fields and the keys inside each field definition still sort. Pinned (the H5 block, plus a 200-run property). Right.
  • Insertion order equals Object.keys order only when no key is integer-like. The field-key grammar is /^[a-z_][a-z0-9_]*$/ (object.zod.ts:2076-2079, main), which admits no such key, so guarantees 5 and 7 hold as the docblock argues (canonicalize.ts:54-58); pinned as properties under object. Right.
  • MetadataItemSchema.hash only changes its describe text (types.ts:100); the regex is unchanged, so no accept set moves. Right. One residue: types.ts:94-95 still says "Equal hashes imply equal specs", which is false on one legacy edge the PR body discloses (see ③, item 10). Doc-level, not blocking.
  • Repository contract invariant 4 is restated as item.hash === hashSpec(item.body, item.ref.type) (repository.ts:24-29, contract-suite.ts:15, :207, :230-235, :270). An external MetadataRepository that still hashes type-blind breaks the invariant only for object bodies with unsorted fields; every in-tree implementation is updated (below). Right, and the restatement is the honest one.

@objectstack/metadata-protocol (sys-metadata-repository.ts):

  • put hashes as ref.type (:539). The optimistic lock still compares the caller's token with the stored stamp (:614-616), never a re-derivation, so an upgrade raises no 409. Right.
  • The no-op short-circuit asks storedBodyUnchanged (:630), which for a type with an ordered map re-hashes the stored bytes under the current rule (:1998-2013); for every other type it stays a stamp comparison (:2004). This is the only repository that persists stamps across the rule change, so it is the only one that needed a content comparison. InMemoryRepository holds no legacy stamps (in-memory-repository.ts:105-108) and FileSystemRepository re-derives its heads at boot (metadata-fs/src/repository.ts:519), so their stamp comparisons are right.
  • A skipped write answers item.hash, the stored stamp (:630-632), so a receipt token over a legacy row is the one the next If-Match must send. Pinned at the repository (sys-metadata-repository.contract.test.ts:690-700) and end to end (the dogfood file, "the receipt's token is honoured by If-Match"). Right.
  • rowToItem re-derives only a null checksum, as its type (:1955). Right.
  • The designer's door reaches put under the canonical singular spelling: saveMetaItem folds at protocol.ts:19452 and puts at :19628; publish folds at :20762 and promotes at :20924; rollback folds at :24388 and :24395 and restores at :24454. revertCommit builds its ref from the stored type (:23876) behind a guard that passes manifest plurals (:23840), so a legacy objects row restores order-blind. That residue is disclosed in the PR body and carried by [finding] revertCommit's restore limb hands a stored manifest-plural type ('objects') to put, so a legacy plural row's field-order restore is skipped as unchanged and still reported restored #21821. Right.
  • No other production caller of hashSpec or canonicalize exists in packages/*/src (the canonicalize at plugin-auth/src/member-role-canonical.ts:245 is a local arrow function, not the import). The ETag and If-Match paths the dev cites do not re-derive the content hash. Right.

@objectstack/metadata-fs (repository.ts): all four hash sites pass ref.type (:263, :363, :519, :1006). A version-pinned get with a pre-change token answers null (:264) and the JSONL log's pre-change hashes no longer match (findMetaForHash), which the changeset discloses as the provenance fallback (.changeset/21790-metadata-fs-ordered-fields-hash.md:9). Right.

Docs: content/docs/concepts/metadata-lifecycle.mdx:149 now states the rule the code implements (keys sort except in spec-declared ordered maps, put() short-circuits on an unchanged body, a pre-release row keeps its stamp as its version token). I found no other hand-written page stating sorted-key hashing. docs/adr/0008-metadata-repository-and-change-log.md:183 still reads "sha256 of canonicalize(body)"; that is a governed ADR's original design text, not a page this PR may edit in passing. Right.

Done-when of the card: "a pure reorder is hashed as a change" holds (canonicalize.ts:206-208, pinned), and "a dogfood test reorders, publishes, reloads and reads the new order" holds: packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts saves a draft reorder, publishes, reads GET /meta/object/:name twice, and reads the stored row, on the showcase over real HTTP. Triage's second pin, an unordered map still hashing equal under a key swap, is the H5 block of test/canonicalize.test.ts. Both pins are on the head, and Test Core and the Dogfood Regression Gate are green on it.

② Semver level

  • @objectstack/metadata-core: minor (.changeset/21790-metadata-core-ordered-fields-hash.md:2). Right: a new exported symbol on an index and two widened signatures is "a purely additive widening of a published package's public surface", which takes at least minor; a fix( that widens an index is minor (.github/workflows/pr-automation.yml:754-760).
  • @objectstack/metadata-protocol: patch (.changeset/21790-metadata-protocol-ordered-fields-hash.md:2). Right: no exported symbol, key, value, payload or error code changes; storedBodyUnchanged is private (sys-metadata-repository.ts:1998).
  • @objectstack/metadata-fs: patch (.changeset/21790-metadata-fs-ordered-fields-hash.md:2). Right: a behaviour fix with no surface change, with its residue stated in the body.
  • skip-changeset is absent and would be wrong: three released packages publish.
  • Nothing is removed or renamed, so no BREAKING banner and no ADR-0087 marker is owed (AGENTS.md:1078-1089).
  • Clause-②: yes. The PR body's line 2 reads Clause-②: yes, and each changeset mirrors it (line 7 of each), which is where the gate reads it (AGENTS.md:1087-1088). The public surface of metadata-core widens, so yes is the true answer; the claim's no (5987515805) was a prediction made before the diff existed, and the dev corrected it on the diff (5988464990). No direction arm is written; the arm is optional (AGENTS.md:1075-1077). yes takes at least minor on one moved package, which metadata-core satisfies; the level axis passed three times on the head (Check Changeset 111625285701, 111625466899 and 111634383656).
  • No governed path is in the 14-file list, so no Tier H or Tier S record is owed by path; this record answers the Clause-②: yes declaration and the needs:contract-review label on the PR.

③ Boundary flags

  1. Open question 1 (5988341325), FileSystemRepository provenance for pre-change log entries, recommended A. Accepted as A. The FS repository is attached as an event source only (packages/metadata/src/plugin.ts:520-539); the manager only calls repo.watch({}) on it (metadata-manager.ts:3018-3020); getRepository() (:2991) has no caller in packages/*/src; no .put( on a repository exists outside the three repository packages. The residue is stated in the fs changeset (line 9). The version-pinned get answering null for an old token (metadata-fs/src/repository.ts:264) is the same residue with the same reach.
  2. Open question 2 (5988341325), inline locale maps, recommended A. Accepted as A. i18n-label-resolver.ts:44 and :47 describe limbs 3 and 6 as a key-insertion-order tie-break mirrored from objectui's pickLocalized, not an authoring contract. The "declaration order is load-bearing" remark at i18n.zod.ts:69-73 is about module load order under OS_EAGER_SCHEMAS, not about map keys. The table's own rule refuses a row inferred from renderer behaviour (canonicalize.ts:89-91). No row is owed.
  3. Patch-1 open question (5988409457), revertCommit hands a stored manifest plural to put, recommended A for this PR and B as its own card. Accepted. Verified at protocol.ts:23840 (guard) and :23876 (ref from the stored type) and :24058 (restore). [finding] revertCommit's restore limb hands a stored manifest-plural type ('objects') to put, so a legacy plural row's field-order restore is skipped as unchanged and still reported restored #21821 exists, is open, graded priority:p3, labelled pm:on-hold, and names option B. The PR body's Acceptance notes carry the residue. Option C is rightly refused.
  4. Deviation (5988341325): the full metadata-protocol suite ran before the last merge. Moot on this head: Test Core 1 to 6 are success here.
  5. Deviation (5988341325): three PR-context gates exited 2 before the PR existed. Moot: "The card this PR closes must claim this branch", "Part-of PR must not also close its card", "No other open PR may claim the same issue" and "No other open PR may claim the same single-writer path" are success on the head.
  6. Deviation (5988409457): a local probe commit made with hooks disabled was reset and never pushed. The branch holds seven commits from e647cfcafe to 046c425d62; none is that commit.
  7. Out-of-scope finding (5988341325), DatabaseLoader's second checksum vocabulary, "carrier: none". The conclusion holds; its premise does not. MetadataManager.register is called from packages/metadata/src/plugin.ts:1199, :1211, :1256 and :1374, so "no caller in packages/*/src" is wrong. register persists through every writable datasource: loader (metadata-manager.ts:802-817), and DatabaseLoader.save skips on an equal key-sorted bare-hex checksum (database-loader.ts:1404-1414, metadata-history-utils.ts:18-30). That door is boot-time artifact priming, not the designer's save-and-publish, which goes through protocol.ts to SysMetadataRepository and is pinned on the real stack by the dogfood file. So the card's door is closed. Escalated, not blocking: the same defect class on that loader deserves its own card, and the report's "no caller" sentence should not be repeated.
  8. Out-of-scope finding (5988341325), packages/objectql/scripts/dry-run-hash-compat.ts hashes type-blind and its checksum_drift arm will fire on new stamps. Accepted as not blocking: it is the PR-10d.1 offline audit (dry-run-hash-compat.ts:1-29), not shipped (objectql/package.json:63-66, files is dist only). Escalated: a one-line follow-up (pass row.type) or a card, so the probe does not report false drift.
  9. Out-of-scope finding (5988341325), I18nLabel maps: answered under item 2.
  10. Acceptance note (PR body): a reorder into sorted order over a legacy row writes a checksum equal to its previous_checksum, so getByHash can find either history row. Verified: getByHash is a findOne by checksum (sys-metadata-repository.ts:488-495); object is executionPinned: false (spec/src/kernel/metadata-plugin.zod.ts:726); rollback resolves by the numeric history version, not by hash (:1033, :1063); and no production code in packages/*/src calls getByHash. Accepted. The one residue is the sentence at types.ts:94-95 (item ① above).
  11. Acceptance note (PR body): FileSystemRepository takes the type from the directory name verbatim. Accepted under item 1 (no production writer).
  12. Owed docs item (5988464990), done per 5988540414: metadata-lifecycle.mdx:149 now matches the code, and Build Docs and Check Documentation Links are success on the head. The Docs Drift Check (5988314939) named only that page, and it also said packages/metadata-core/src/repository.ts yielded no anchor; I read that file's change myself (repository.ts:24-29, the invariant text) and found no page it falsifies.
  13. Main moved (5988540414): the head does not merge ba57588665. CI judged the merge of this head into ba57588665 (5988314939), so the green gates cover the newer tree. origin/main has since moved again; the merge-base is still the PR's base sha, and the diff is clean against it.
  14. The claim (5987515805) predicted Clause-②: no and said a contract review becomes owed if packages/spec/src/** is edited. No spec file is edited; the review is owed instead by the yes the diff produced, which the dev declared (5988464990).

Implemented-by: claude/issue-21790-ordered-fields-hash
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

Adopted by domain:engine#1 (session_017ErfyP2Rx7XWHJA27QjyUi) at 2026-10-05T06:06Z as the record of head 046c425d62, the current head. needs:contract-review is removed in this act. The seat carries the record's escalation 7 (DatabaseLoader's key-sorted checksum on the MetadataManager.register path) as its own finding card; items 8 and 10's doc residue stay recorded here.


Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants