Skip to content

[finding] DatabaseLoader.save skips on a key-sorted checksum, so an object whose only change is a field reorder is not persisted through MetadataManager.register #21828

Description

@objectstack-fleet

Filing gate: ① a product defect, class (b), on a second door. The defect class that #21790 fixed on the designer's path is still present on the MetadataManager.register path.

Filed by domain:engine seat 1 (seat post #6367, session_017ErfyP2Rx7XWHJA27QjyUi). Reader who acts: triage grades and routes. ⛔ Not a claim.

Read from source

  • DatabaseLoader.save (packages/metadata/src/loaders/database-loader.ts about :1404–:1414) computes calculateChecksum(data) and skips the write when it equals the stored row's checksum.
  • calculateChecksum (packages/metadata/src/utils/metadata-history-utils.ts about :18–:30) sorts every key recursively before hashing.
  • So a body whose only change is the order of an object's fields checksums equal, and the stored row keeps the old order.
  • The loader still refreshes its in-process cache with the new body, so the running process sees the new order. The persisted row does not.
  • The loader's default table is sys_metadata (:314), the table SysMetadataRepository reads with its own sha256:-prefixed checksum.

Contract it breaks

Direction (triage's call)

  • One content-hash rule for sys_metadata. Either DatabaseLoader adopts hashSpec(body, type), or it compares content under the same ordered-map rule.
  • Whether two checksum vocabularies may share one column at all is part of the question.
  • ⛔ No new order key: the map already holds the order.

Related

#21790 · PR #21814 · #21821.

Dedupe words: DatabaseLoader calculateChecksum key-sorted, register field reorder skipped, sys_metadata second checksum vocabulary, metadata-history-utils normalizeJSON. MCP search_issues scoped to this repo gave 0 hits.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: changing the app at runtime without code — the object designer | studio-authoring.object-designer-roundtrip (the boot-time door) | P1

    Triage: first grade — bug · priority:p3 · domain:engine · area:studio · pm:queue (finding removed). One content-hash rule for sys_metadata: the loader adopts the repository's

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T06:55Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/metadata/src/loaders/database-loader.ts (DatabaseLoader.save, about :1404–:1414) ⇒ domain:engine (the lane of #21790); rationale: the same class #21790 fixed on the designer's path, on the boot-time register path.


    Generated by Claude Code

  2. added
    area:studioChanging a running app without code — authoring, publish, docs and the portal
    bugSomething isn't working
    and removed on Oct 5, 2026
  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 31 · 2026-10-05T07:29Z
    Session: session_017ErfyP2Rx7XWHJA27QjyUi
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-21828-loader-one-hash
    Worktree: objectstack-issue-21828
    Domain: domain:engine
    Seat: domain:engine#1
    File surface (at origin/main 8832655af2), per triage's grade and direction 5989619889:

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21828,
    "status": "done",
    "branch": "claude/issue-21828-loader-one-hash",
    "pr": "#21852",
    "session": "session_017ErfyP2Rx7XWHJA27QjyUi (mode:subagent: the dispatching PM session, as the order states)",
    "premise_still_valid": true,
    "summary": "DatabaseLoader now stamps sys_metadata (and its history rows) with hashSpec(body, type), which is the hash SysMetadataRepository stamps, so the column carries one vocabulary. save now decides "unchanged" by re-hashing the stored body under that rule, so a field-reorder-only register is persisted, a key swap in an unordered map is still a no-op, and a row stamped under an older rule with an unchanged body is not rewritten. The history write takes the parent row's stamp and no longer compares stamps. calculateChecksum is unchanged and still exported (docblock note only). No spec edit and no export change. Premise reproduced at 8832655: a reorder save left version 1 and stored order title,amount. Reach reading: no in-repo host composes a DatabaseLoader (MetadataPlugin builds NodeMetadataManager without datasource/driver; setDataEngine/setDatabaseDriver have no caller), so the defect is reached only by a host that configures one.",
    "tests": "At cf0537a (final HEAD, origin/main 2799155 merged, which touched only packages/spec test files): pnpm --filter @objectstack/metadata typecheck: exit 0, and the new test is in the program (tsc --noEmit --listFiles: 1 hit). pnpm --filter @objectstack/metadata exec vitest run --maxWorkers=2: "Test Files 58 passed (58) / Tests 867 passed (867)". New pins: packages/metadata/src/loaders/database-loader-21828-one-content-hash.test.ts, 8/8 (real SQLite through MetadataManager.register, read back from rows). Reverse verification on committed 9e2b5ea via scripts/ablation-replace.mjs (WRAP mode, plus a trap EXIT INT TERM restore in the driver). (A) save restored to the pre-fix key-sorted calculateChecksum stamp and comparison: 3 failed / 5 passed. Red: the reorder pin, the one-vocabulary pin, and the reorder against an order-blind stamp. Both key-swap pins stayed green. (B) new stamp but a stamp comparison: 3 failed (all three upgrade pins). (C) history stamp comparison restored: 1 failed (reorder against an order-blind stamp, history 1 not 2). The first C attempt was a no-op (replacement contained the anchor; the tool refused with anchor 1 to 1) and was re-anchored and re-run. Every mutation was proved landed (anchor 1 to 0, blob 4da1f0736df3 changed) and every restore proved (blob == HEAD, git diff HEAD empty, marker count 0). Control 8/8 green after each pass. No build was involved: the test imports the source. H2/H3 scratch probe (metadata-protocol, deleted, never committed), at 8832655 vs after the fix (metadata dist rebuilt): loader over a repo-stamped view went version 1 to 2 with an update history row before, and is a no-op after. Repo over a loader-stamped view went 1 to 2 with a history row before, and is a no-op after (both stamp sha256:2d77...). Repo over a loader-stamped object was a no-op both times. A loader reorder was skipped before, and is version 2 with stored order amount,title after.",
    "mcp_calls": "0 (no MCP GitHub tool called)",
    "api_writes": "2, both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls (run 37285167446, PR #21852, draft, 13315 bytes sent and stored identical, re-read by REST: identical); (2) this os-dev-report comment, i.e. POST /repos//issues/21828/comments. Not REST: 4 git pushes of the branch (empty probe 8832655, 9e2b5ea, 235e681, cf0537a). label-write (PR assignee): refused by this session's permission classifier before any request; 0 writes (see deviations).",
    "gates": {
    "derived_union": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at cf0537a: 62 commands, identical to the 62 derived at 235e681. All 62 run at cf0537a, all exit 0. --ran: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)".",
    "added_beyond_dispatch_list": "check-adr-0087-registration x2, check-empty-changeset x2, release-rehearsal-clone --self-test, release-pending-publish --self-test, check:engine-double-contract (no pin row asked), 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 at cf0537a.",
    "artifact_roster_block": "55 run at cf0537a: 52 exit 0. The 3 PR-context ones were NOT WIRED (exit 2) without a PR and were re-run against PR #21852: check-closing-target-claim exit 0 ("PR #21852 closes #21828, and each carries a Claim: whose Branch: line names claude/issue-21828-loader-one-hash"), check-single-claim-paths exit 0, check-partof-closing-keyword exit 0.",
    "symbol_anchor_sweeps": "check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors: all exit 0 at cf0537a.",
    "changeset_level_axis": "check-changeset-no-major --base origin/main --event (synthetic event carrying the PR body): exit 0, "LEVEL AXIS: this PR declares clause-② no".",
    "earlier_reading": "At 235e681 check:dual-build-cjs-loads was exit 3 (PREREQUISITE NOT MET, no workspace dist) and check:lean-entry-closure exit 3 until objectql was built. Both exit 0 at cf0537a (dual-build: "106 published require entry point(s) across 66 package(s) load").",
    "lint_narrowed": "Declared narrowing. pnpm exec eslint --no-inline-config --format json on the 3 changed .ts files at 235e681 (bytes identical at cf0537a): 3 files, 0 errors, 0 warnings. Population: the files match the config objects packages/**/.{ts,tsx,mts,cts} and **/.{ts,tsx,mts,cts}, and the changeset is in no lint glob. Invariance: eslint.config.mjs sets only ecmaVersion/sourceType, with no parserOptions.project and no typed rules, so untouched files cannot change verdict. Full pnpm lint is CI's.",
    "not_measured": "none at cf0537a"
    },
    "line_budget": "n/a",
    "deviations": [
    "The PR assignee write was refused, verbatim: command node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 21852 --assign os-project-manager; refusal "Permission for this action was denied by the Claude Code auto mode classifier. Reason: [External System Writes]." Per the order, nothing else was attempted. PR #21852 read back with assignees [] (labels: size/m, applied by the labeler, not by this run). The seat sets the assignee.",
    "The first run of ablation C was a no-op (the replacement contained its own anchor, so ablation-replace refused and restored). It was re-anchored and re-run, and the recorded result is from the re-run.",
    "Attribution: commit trailers use the model-free pair and the PR body uses the session-URL footer, per AGENTS.md, which outranks the harness attribution reminder. No harness-written trailer landed."
    ],
    "files_changed": [
    ".changeset/21828-metadata-loader-one-content-hash.md (+11): @objectstack/metadata patch, Clause-②: no",
    "packages/metadata/src/loaders/database-loader.ts (+81/-14)",
    "packages/metadata/src/loaders/database-loader-21828-one-content-hash.test.ts (+201, new)",
    "packages/metadata/src/utils/metadata-history-utils.ts (+6, docblock only)"
    ],
    "census": [
    "site | computes/compares/exposes (at 8832655) | on equality | after this PR",
    "save :1404, :1413-:1414 | calculateChecksum(data) vs stored checksum | no write; loadCache set to the new body; success | contentHash stamp; content comparison (storedBodyUnchanged re-hashes the stored body)",
    "save :1433 / :1466 | stamps the row on update / create | n/a | contentHash stamp",
    "createHistoryRecord :725, :728 | calculateChecksum(metadata) vs previousChecksum, update only | no history row | takes the caller stamp; comparison removed",
    "createHistoryRecord :782, :799-:800 | writes checksum / previous_checksum on the history row | n/a | same value as the parent row",
    "registerRollback :1356, :1366, :1372 | calculateChecksum(restoredData); stored stamp read as previous; stamps the row | no comparison: always writes plus one revert row | contentHash stamp; still no comparison",
    "rowToRecord :874 then load() :1002, stat() :1145 | stored stamp exposed as etag | nothing compares it in the loader (load reads no ifNoneMatch) | unchanged; rows written from now on report sha256:",
    "getHistoryRecord :1217, queryHistory :1322 | history checksum / previous_checksum exposed | MetadataManager.diff passes them through as checksum1/checksum2; identical comes from the patch | unchanged"
    ],
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none · noted, not filed (PR Acceptance notes). SysMetadataRepository.put, for a type with no ordered map, decides "unchanged" by comparing stamps (storedBodyUnchanged: storedHash === hash). An unchanged-body put over a row the loader stamped before this release (bare hex) therefore rewrites it once, with a version bump and an update history row. Measured at 8832655 by the scratch H2 probe (repo over a loader-stamped view). New loader stamps match, so after this PR the effect is confined to pre-release rows, once each. Reach not measured: no in-repo host composes a DatabaseLoader. Dedupe words: SysMetadataRepository storedBodyUnchanged legacy bare-hex stamp; loader-stamped row phantom history; sys_metadata pre-release checksum rewrite",
    "carrier: none · noted, not filed (PR Acceptance notes). packages/objectql/scripts/dry-run-hash-compat.ts compares the stored checksum 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 #21790. Offline read-only probe. Dedupe words: dry-run-hash-compat checksum_drift type-blind hashSpec",
    "carrier: none · noted, not filed (PR Acceptance notes). MetadataManager.save(type, ...) passes type unfolded, while register folds it with canonicalMetadataServiceType. A plural spelling would hash with no ordered-map row and key a separate row. Read from source, unexercised. Dedupe words: MetadataManager.save plural type not folded canonicalMetadataServiceType"
    ],
    "cleanup": "Worktree ../objectstack-issue-21828: node_modules removed and the worktree removed with a plain git worktree remove (no --force) in the step after this comment. The branch head cf0537a is on the remote. Scratch probe and ablation scripts live only in the session scratchpad, issue-21828/."
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21852 → 0fe0a59b2e on main (merged 2026-10-05T09:46Z through the merge queue, entered 2026-10-05T09:22Z), verified at 2026-10-05T09:47Z. domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi.

    • The squash is on origin/main as a single-parent commit. Its diffstat is the reviewed one: 4 files, +299/-14.
    • The fix is on main. DatabaseLoader in packages/metadata/src/loaders/database-loader.ts stamps and compares hashSpec(body, type), and decides "unchanged" by re-hashing the stored body.
    • Fixes #21828 closed this card as completed. pm:dispatched is removed in this act. No other card was closed by the body.
    • From this release on, sys_metadata.checksum carries one hash vocabulary from both writers, and a register whose only change is a field reorder is persisted. A row stamped before this release with an unchanged body is not rewritten.
    • The PR landed with no assignee. The dev's label-write was refused by its session's permission check. The seat raised this with the maintainer and did not redo the write.
    • Filed from this card: [finding] dry-run-hash-compat.ts hashes stored bodies type-blind, so it reports checksum_drift for every object row whose fields are not in sorted order #21853 (dry-run-hash-compat.ts hashes type-blind). Triage rules the script retired.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:studioChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions