Skip to content

fix(metadata): DatabaseLoader stamps and compares hashSpec(body, type), so a field-reorder-only register is persisted (#21828) - #21852

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21828-loader-one-hash
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21828-loader-one-hash

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21828
Clause-②: no

What was wrong

DatabaseLoader.save stamped sys_metadata.checksum with calculateChecksum(data), which sorts every map before hashing, and skipped its write whenever that stamp equalled the stored one. An object whose only change was the order of its fields hashed equal, so MetadataManager.register refreshed the loader's cache with the new order while the persisted row kept the old one. This is the class of defect #21790 repaired on the designer's path, here on the register path.

Reproduced at 8832655af2 before the change (scratch probe, not committed): a DatabaseLoader.save of { fields: { amount, title } } over a row holding { fields: { title, amount } } left version 1, one history row, and stored order title,amount.

The fix

Landing: packages/metadata/src/loaders/database-loader.ts, as the claim predicted. The producer of the stamp is the loader itself, so nothing moved to another package.

  • One stamp. Every loader write (save create and update, registerRollback) stamps contentHash(json, type), which is hashSpec(JSON.parse(json), type) from @objectstack/metadata-core. That is the hash SysMetadataRepository stamps on the same column, so the column carries one vocabulary (sha256: + 64 hex). It hashes the JSON the loader stores, so a value JSON cannot carry (a function, an undefined property) is dropped as the stored column drops it, and hashSpec is never handed what it refuses.
  • One comparison, about content. save decides "unchanged" by re-hashing the STORED body under hashSpec(body, type) (storedBodyUnchanged), the comparison SysMetadataRepository.put makes for an ordered-map type. A stored stamp cannot answer it, because it may come from an older rule (H3).
  • The history write no longer compares stamps. createHistoryRecord takes the parent row's new stamp from its caller, so the history row carries the same value. Its own 'update' comparison (new stamp vs previous stored stamp) is removed. Its only 'update' caller is save, which reaches it after the content comparison. A second, stamp-based comparison can only be wrong: against an order-blind stamp, a reorder into sorted key order hashes equal and would get no history row (ablation C below).
  • ⛔ No second order-aware checksum, no order key: the map already holds the order, per triage.
  • calculateChecksum keeps its behaviour and its export (H4). Its docblock now says it is not the sys_metadata.checksum stamp.

No packages/spec/src/** edit, no export added, removed or changed. The built dist/index.d.ts differs only in doc comments: DatabaseLoader.createHistoryRecord is private and emitted without a signature, and contentHash and storedBodyUnchanged are module-private. So no contract review is owed beyond what the Clause-②: no line states.

H1 — every checksum decision in the loader (at 8832655af2)

Site Computes, compares or exposes On equality After this PR
save :1404, :1413–:1414 calculateChecksum(data) vs the stored checksum No write; cache set to the new body; success contentHash stamp; content comparison via storedBodyUnchanged
save :1433 / :1466 Stamps the row on update / create n/a contentHash stamp
createHistoryRecord :725, :728 calculateChecksum(metadata) vs previousChecksum, for 'update' only No history row Takes the caller's stamp; no comparison
createHistoryRecord :782, :799–:800 Writes checksum / previous_checksum to the history row n/a Same columns, same value as the parent row
registerRollback :1356, :1366, :1372 calculateChecksum(restoredData); reads the stored stamp as previous; stamps the row No comparison: always writes and appends one revert row contentHash stamp; still no comparison
rowToRecord :874 → load() :1002, stat() :1145 Exposes the stored stamp as etag Nothing compares it in the loader (load reads no ifNoneMatch) Unchanged; a row written from now on reports sha256:
getHistoryRecord :1217, queryHistory :1322 Expose history checksum / previous_checksum MetadataManager.diff passes them through as checksum1 / checksum2 and decides identical from the patch Unchanged

H2 — two vocabularies in one column, measured

Scratch probe in packages/metadata-protocol (deleted, never committed): one in-memory engine shared by a DatabaseLoader and a SysMetadataRepository, each writer meeting the other's stamp on an unchanged body.

Case At 8832655af2 After this PR (9e2b5ea54d)
Loader save over a repository-stamped view Rewritten: version 1 to 2, stamp sha256:2d77… to bare 2d77…, update history row No write, version 1, 1 history row
Loader save over a repository-stamped object Rewritten: version 1 to 2, update history row No write
Repository put over a loader-stamped view Rewritten: version 1 to 2, bare to sha256:, update history row No write: both writers stamp sha256:2d77…
Repository put over a loader-stamped object No write: the repository re-hashes the stored body for an ordered-map type No write: both stamp sha256:3c7d…

So before this PR each writer's first unchanged write over the other's row was a phantom version bump with a history row recording no change. The view's digest was the same hex in both writers, sha256: prefix aside, because a type with no ordered map canonicalizes to the same sorted JSON.

H3 — the upgrade without phantom bumps

After the change a row the loader stamped before this release carries a bare-hex stamp that no sha256: hash equals. Comparing stamps would therefore rewrite every such row on the first register after upgrade, with a version bump and an update history row. Ablation B measures exactly that. So save compares content: a legacy-stamped row with an unchanged body is not rewritten and keeps its stamp until its content next changes. A reorder of object.fields is still a change, including a reorder into sorted key order against an order-blind stamp.

H4, H5

  • H4. calculateChecksum (index.ts:45) is unchanged and still exported. Nothing in this repository calls it any more except the new test, which uses it to produce a legacy stamp.
  • H5. Readers of a stored loader stamp: the two loader ETags and the two history readers in H1, MetadataManager.diff (pass-through), and SysMetadataRepository, which reads the stamp as its version token: rowToItem, the optimistic lock, and the storedBodyUnchanged short-circuit (H2). The REST metadata ETag is computed from the served body (protocol.ts getMetaItemCached), not from the stored stamp. protocol.ts migrate-stored hands the stored stamp back as storedParentVersion, where it is compared with the same stored stamp. Outside the runtime, the offline probe packages/objectql/scripts/dry-run-hash-compat.ts compares the stored stamp with a type-blind hashSpec(body); see Acceptance notes.

Pins

New file packages/metadata/src/loaders/database-loader-21828-one-content-hash.test.ts: real SQLite (driver-sqlite-wasm), through MetadataManager.register, read back from the rows.

  • §A a field-reorder-only register persists the new order (version 2, create + update history); the row and its history row carry hashSpec(body, 'object'), one value; a rollback restores the earlier order in the same vocabulary.
  • §B an unordered map's key swap compares equal: no write and no history row, for an object (keys around and inside fields) and a view.
  • §C the upgrade: a row stamped by the old calculateChecksum with an unchanged body is not rewritten (object and view); a reorder into sorted key order against an order-blind sha256: stamp is persisted.

Reverse verification

Committed first (9e2b5ea54d). Each mutation went through node scripts/ablation-replace.mjs in WRAP mode, with a trap … EXIT INT TERM restore in the driver script. Each landing was proved on disk (anchor count 1 to 0, blob 4da1f0736df3 changed), and each restore was proved with blob equal to HEAD and an empty git diff HEAD. The loader test imports ./database-loader.js from source, so no build was involved.

Ablation Mutation Red Green
A save restored to the pre-fix key-sorted calculateChecksum stamp and stamp comparison §A reorder persists, §A one-vocabulary stamp, §C reorder against an order-blind stamp (3) §A rollback, both §B key-swap pins, §C legacy unchanged ×2 (5)
B New stamp, but newChecksum === previousChecksum instead of the content comparison all three §C upgrade pins (3) §A ×3, §B ×2 (5)
C The history write's stamp comparison restored §C reorder against an order-blind stamp (history 1, not 2) the other 7

Control on the unmutated HEAD, run after each driver pass: 8/8 green. The first attempt at C was a no-op: its replacement still contained its anchor, so ablation-replace refused (anchor count 1 to 1) and restored. It was re-anchored on const historyId = generateId(); and re-run. The table records the re-run.

Tests

At cf0537aa08 (the final commit, origin/main 2799155678 merged; the merge touched only packages/spec test files):

  • pnpm --filter @objectstack/metadata typecheck: exit 0. The new test is in the program: tsc --noEmit --listFiles counts it once.
  • pnpm --filter @objectstack/metadata exec vitest run --maxWorkers=2: 58 files, 867 tests passed.
  • Consumers: no public surface moved (no export, no .d.ts signature, no spec), so per the dispatch contract only the package's own tests are owed. No other package's test exercises DatabaseLoader writes or stamps: 10 files reference it, with zero save/register/checksum/etag hits.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at cf0537aa08: 62 derived commands, the same 62 as at 235e68165e. All 62 were run at cf0537aa08, every one exit 0, and --ran reconciles: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED".

  • Beyond the dispatch list, the change set added: check-adr-0087-registration ×2, check-empty-changeset ×2, release-rehearsal-clone --self-test, release-pending-publish --self-test, check:engine-double-contract, check:objectql-double-limit, check:objectui-changeset, check:pm-changeset-deadline-census, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher. All exit 0.
  • Artifact-roster block (55 printed outside the total), at cf0537aa08: 52 exit 0. check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths answered NOT WIRED (no pull-request context). They are re-run against this PR in the report on the card.
  • The four symbol-anchor sweeps: check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors, all exit 0.
  • check:engine-double-contract asked for no pin row (no new double).
  • Lint, narrowed and declared. pnpm exec eslint --no-inline-config --format json on the 3 changed .ts files at 235e68165e (identical bytes at cf0537aa08) linted 3 files, with 0 errors and 0 warnings. Population: all three match the config's packages/**/*.{ts,tsx,mts,cts} and **/*.{ts,tsx,mts,cts} objects. The changeset is in no lint glob. Invariance: every config object sets only ecmaVersion / sourceType, with no parserOptions.project and no typed rules, so this diff cannot move the verdict on any untouched file. The full pnpm lint is CI's.

Docs

No hand-written page states the loader's checksum rule. content/docs/concepts/metadata-lifecycle.mdx states the sha256: rule for the repository's put(), and it is now also true of new loader writes. No doc edit.

Acceptance notes

  • Residual on the repository side, for rows stamped before this release. SysMetadataRepository.put answers "unchanged" for a type with no ordered map by comparing stamps. So an unchanged-body put over a row the loader stamped before this release (bare hex) still rewrites it once, with a version bump and an update history row (H2, row 3, at 8832655af2). New loader stamps match, so this is confined to pre-release rows and converges after one write each. Reach not measured: no in-repo host composes a DatabaseLoader (only new MetadataManager({ datasource, driver }) or setDataEngine does, and nothing in this repository calls either). Not filed.
  • packages/objectql/scripts/dry-run-hash-compat.ts compares a stored stamp with a type-blind hashSpec(body). So it reports checksum_drift for every object row whose fields are not in sorted order, whichever writer stamped it since 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. Offline probe, not filed.
  • MetadataManager.save(type, …) passes type unfolded, where register folds it with canonicalMetadataServiceType. A plural spelling would hash with no ordered-map row and key a separate row. Read from source, unexercised, not filed.

Generated by Claude Code

claude added 3 commits October 5, 2026 07:45
…), so a field reorder is persisted

The loader stamped sys_metadata with calculateChecksum (bare hex, every map
sorted) and skipped its write when the new stamp equalled the stored one, so a
register whose only change was the order of an object's fields was never
persisted. It now stamps the hash SysMetadataRepository stamps on the same
column, hashSpec(body, type), and decides "unchanged" by re-hashing the stored
body under that rule, so a row stamped under an older rule is not rewritten
when its body is unchanged. The history write takes the parent row's stamp and
no longer compares stamps.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata, touching 5 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata/src/utils/metadata-history-utils.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/protocol/kernel/metadata-service.mdx (via DatabaseLoader (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via DatabaseLoader (symbol, a top-level class))

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.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata/src/utils/metadata-history-utils.ts) — pages documenting those are invisible to this run
  • 1 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 — 17 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 48297ad980b1137e8db1df46729f9890d0f0608e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 48297ad980b1137e8db1df46729f9890d0f0608e

⚠️ 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 48297ad980b1137e8db1df46729f9890d0f0608e → 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

ACCEPT (seat review) — PR #21852 at head cf0537aa08

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T08:45Z. The os-dev report is on #21828 (5991099861). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main.

    • The first lines are Fixes #21828 and Clause-②: no.
    • The closing-keyword scan finds #21828 only.
    • No assignee. The dev's label-write --assign os-project-manager was refused by its session's permission check before any request. The seat does not redo that write, and has raised it with the maintainer.
  • Scope: 4 files, +299/-14:

    • packages/metadata's database-loader.ts;
    • a docblock note in metadata-history-utils.ts;
    • the new pin file;
    • the changeset.

    NOT governed, no packages/spec/src/** path, and no export moves (calculateChecksum is unchanged and still exported). So Clause-②: no is right, and no contract review is owed.

  • The diff, read: triage's direction (5989619889), as ruled.

    • DatabaseLoader stamps sys_metadata and its history rows with hashSpec(body, type), the hash SysMetadataRepository stamps. One column now carries one vocabulary.
    • save decides "unchanged" with storedBodyUnchanged, which re-hashes the stored body under that rule:
      • a reorder of an object's fields is written;
      • a key swap in an unordered map is still a no-op;
      • a row stamped under the old bare-hex rule with an unchanged body is not rewritten (H3: no version bump after upgrade).
    • The history write takes the parent row's stamp instead of comparing stamps again.
    • ⛔ No second order-aware checksum. ⛔ No order key.
  • Census (H1): every checksum site is listed with its before and after. The ETag readers in load() and stat() compare nothing in the loader, and the changeset states their new sha256: format.

  • Reach, stated honestly: no in-repo host composes a DatabaseLoader. MetadataPlugin builds NodeMetadataManager with no datasource or driver, and setDataEngine / setDatabaseDriver have no caller. So the defect reaches only a host that configures one. That is consistent with triage's p3.

  • Pins: database-loader-21828-one-content-hash.test.ts, 8 tests on real SQLite through MetadataManager.register, read back from rows. They cover:

    • the reorder;
    • the key swap;
    • one vocabulary;
    • three upgrade cases against old-rule stamps.
  • Reverse verification: three separate ablations from committed 9e2b5ea54d.

    • (A) The pre-fix key-sorted stamp and comparison: 3 red, the key-swap pins green.
    • (B) The new stamp with a stamp comparison: the 3 upgrade pins red.
    • (C) The history stamp comparison restored: 1 red, after the first no-op attempt was re-anchored.
    • Every restore was proved by blob equality and an empty git diff HEAD.
  • Changeset, checked sentence by sentence:

    • patch for @objectstack/metadata, with Clause-②: no.
    • The one-vocabulary, upgrade and ETag bullets each match the diff and the pins.
  • Evidence: packages/metadata passes 867 tests in 58 files. The typecheck is green, and --listFiles compiles the new pin.

  • Gates:

    • dispatch-gates --ran: 62 of 62 exit 0.
    • The roster and the four symbol-anchor sweeps pass.
    • The 3 PR-context guards exit 0 against this PR.
  • CI: read at landing.

Recorded, not filed (the PR's Acceptance notes):


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

Projects

None yet

1 participant