Skip to content

fix(metadata-protocol): seed replay compares time, datetime and date fields in their stored form - #24617

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24562-seed-replay-time-equality
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24562-seed-replay-time-equality

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24562
Clause-②: no

What changed

SeedLoaderService.isNoOpReplay (packages/metadata-protocol/src/seed-loader.ts) now compares a field the object declares time, datetime or date in its stored form. Both sides go through temporalStorageForm (@objectstack/core), the rule SqlDriver and InMemoryDriver already apply on write (ADR-0053). Every other field keeps the comparison it had.

  • The kinds come from the object definition loadDataset already resolves (temporalFieldKinds, next to seedValuesInStoredForm). They are passed explicitly to both write paths that call isNoOpReplay: the batched decideWriteAction (all three call sites) and the sequential writeRecord used by self-referencing objects. The new parameter is required, so no call site can leave it out.
  • "Any doubt reads as changed" still holds. temporalStorageForm returns a value it cannot read unchanged, so an unparseable value equals only its own identical spelling. A non-text result (an Invalid Date, a structure) never compares equal.
  • Nothing a seed may declare changes, and nothing any write stores changes. This is a comparison inside the replay only (the triage ruling). No consumer-side tolerance was added anywhere.

Measured: mechanism before and after, on a real driver

This used a scratch harness (not committed): a real ObjectQL and SqlDriver (better-sqlite3) on one SQLite file DB. Each boot is a new driver, engine and loader. Results:

scratch object (time / datetime / date), seed spelled short
  stored after boot 1: '09:15' -> '09:15:00'; '2026-06-17T14:30:00Z' -> '2026-06-17T14:30:00.000Z';
                       date written '2026-06-17T00:00:00Z' -> '2026-06-17'
  base 2667e2402  boot 2 (same seed):     updated=3 skipped=1  (short-time, short-datetime, iso-date rewritten)
  fix  09b324c5b  boot 2 (same seed):     updated=0 skipped=4
  fix  09b324c5b  boot 3 (time+instant changed): updated=2 skipped=2  (exactly the two changed rows)

showcase: every examples/app-showcase object definition + every ShowcaseSeedData dataset on a showcase-owned object (127 rows)
  base 2667e2402  boot 2: engine.update on 5 rows -- Specimen — Full, INV-1001 · Consulting hours, INV-1004 · Consulting hours,
                          and their two parent invoices (summary recompute)
                  per-key causes: lines -> service_start '09:15' vs '09:15:00' (and '13:40');
                                  Specimen — Full -> f_datetime, f_time AND f_password
  fix  09b324c5b  boot 2: engine.update on 1 row -- Specimen — Full, f_password only

The date kind has the same spelling gap (an instant written to a date field), so it is covered too.

What this PR does not fix: Specimen — Full still gets a new updated_at on every boot. The cause is a second, independent mechanism. The loader reads existing rows through engine.find, which masks a password field to •••••••• in every context, so the seed's plaintext hunter2 never equals the stored value. It is not a temporal-spelling issue. Fixing it means deciding how a replay compares a credential it cannot read, so it is reported to the seat as a separate finding and left out of this PR. So of the card's 5 churning showcase rows, 4 stop churning here; the fifth keeps churning until that finding has its own fix.

The full objectstack dev two-boot on the stock showcase is NOT MEASURED: the CLI's build closure is 63 packages, which is too much for this container's shared verify lock. The harness above loads the showcase's own object definitions and seed data through the same SeedLoaderService, ObjectQL and SqlDriver, and on base it reproduces the card's exact 5 rows.

Tests

  • New pins in packages/metadata-protocol/src/seed-loader-replay.test.ts, reusing the file's pinned faithful engine double. Its insert and update are overridden to write temporal fields in the driver's stored form and to stamp updated_at. Each case asserts which write path ran (a writeRecord spy):
    • replaying the unchanged short-spelled seed ('09:15', '…T14:30:00Z', zone-naive '2026-06-17 14:30', an instant on a date field, plus a stored-spelling control) issues no update and leaves the store, updated_at included, deep-equal. 4 cases: batched and sequential paths, modes upsert and update;
    • positive control: a changed wall clock, instant and day are each rewritten, only their rows, and stored in the new value's form (2 cases);
    • an unparseable time and datetime still read as changed (2 cases).
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/seed-loader-replay.test.ts: 13 passed (5 existing + 8 new).
  • pnpm --filter @objectstack/metadata-protocol typecheck && pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 at 7cad49c76 (the final head, after merging origin/main 5a871e4e4 and rebuilding the package closure): typecheck exit 0; 248 files passed, 3 skipped; 20731 tests passed, 19 skipped. The package tsconfig includes src/**/*; --listFiles counts the test file.
  • Ablation (one-shot, committed fix at 09b324c5b, through scripts/ablation-replace.mjs; tests import ./seed-loader.js from source, so no dist/ is involved). The if (kind) stored-form block was deleted: anchor 1 to 0, blob ee3eb38f87de to 206fd67db529. Predicted direction: all 8 new cases red, 5 existing green. Observed: 8 failed, 5 passed. The replay negatives failed with "expected not to be called, called 4 times" (the 4 short-spelled rows); the control and unparseable cases failed by rewriting extra rows. Restored: blob equals HEAD ee3eb38f87de, git diff HEAD empty.

Gates

Locally, the derived set from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run at the final head 7cad49c76. --ran reconciliation: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0.

  • That includes check:engine-double-contract (OK, 1031 pinned), check:durability-log-level, check:nul-bytes, check:type-check-debt, check:dual-build-cjs-loads (111 require entry points load), check:test-source-alias, check:cross-package-test-inputs, check:published-files and check-adr-0087-registration (1 non-breaking changeset).
  • check-plugin-teardown-shape --self-test needs a positive-control commit that lay outside this shallow clone. That commit was fetched with --depth=1, and the self-test then passed with 48 cases.
  • test:repo and the whole-repo lint: not run locally, declared to CI.

Acceptance notes

  • Changeset: .changeset/24562-seed-replay-time-equality.md, @objectstack/metadata-protocol patch.
  • seedValuesInStoredForm (a Date resolved by a CEL envelope, put in its stored form before the write) is untouched. The new helper reads the definition's field types the same way.
  • @objectstack/metadata-protocol has no dependency on a driver package, so the pin uses the existing double with the drivers' own storage rule, not a real driver. The real-driver reading is the measurement above. It was a one-shot and is not a permanent test, by the dispatch file surface.

Generated by Claude Code

… stored form

isNoOpReplay compared a seed's time / datetime / date value with the
driver's stored spelling literally, so '09:15' against stored '09:15:00'
and '...T14:30:00Z' against '...T14:30:00.000Z' read as changed and the
row was rewritten on every boot (updated_at bumped, lifecycle validation
re-run, a phantom history edit). Both sides of a field the object declares
temporal now go through temporalStorageForm, the one rule every driver
applies on write; a value that rule cannot read still reads as changed.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/data-modeling/validation.mdx (via SeedLoaderService (symbol, a top-level class))
  • content/docs/protocol/objectql/state-machine.mdx (via SeedLoaderService (symbol, a top-level class))

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

  • content/docs/releases/v17/17-1.mdx (via SeedLoaderService (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
  • 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 — 11 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 5a871e4e41b110d5d4b0cdcc325a75723740bb31 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5a871e4e41b110d5d4b0cdcc325a75723740bb31

⚠️ 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 5a871e4e41b110d5d4b0cdcc325a75723740bb31 → 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 · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-19T16:35Z

Reviewed against GitHub on head 7cad49c762, not against the report.

  • Form: draft into main. The body's first line is Fixes #24562, and Clause-②: no sits at a line start. A closing-keyword scan of the whole body finds one hit, Fixes #24562.
  • Scope: 3 files, +245 / −15: seed-loader.ts, seed-loader-replay.test.ts and the changeset. All are inside claim 6270051072's file surface. Nothing under content/docs/releases/, and no governed path.
  • Changeset (@objectstack/metadata-protocol patch), checked sentence by sentence against the diff:
    • "compared a seed value with the stored one as text" matches the removed String(a) === String(b) fall-through for these kinds.
    • "A field the object declares time, datetime or date is now compared in its stored form" matches the new kind branch, fed by temporalFieldKinds.
    • "What a seed may declare, and what any write stores, is unchanged" holds: the diff touches the comparison path and its parameter threading only.
  • Spot-check on origin/main: temporalStorageForm (packages/core/src/utils/temporal-storage-form.ts) reads a Date instance for all three kinds (instantMs takes value instanceof Date). So a driver that hands temporal columns back as Date still compares equal, and the old Date branch is not lost for these kinds. An unreadable value comes back unchanged and equals only its own spelling, which is pinned.
  • Pins: 8 new cases on both write paths. The replay negatives assert engine.update is never called and the store, updated_at included, is deep-equal. The positive control asserts exactly the 3 changed rows are rewritten. Unreadable values still read as changed. The body's ablation: 8 red, 5 green, restore proven.
  • Accepted deviations: origin/main 5a871e4e4 merged before the PR opened. The stock-showcase objectstack dev two-boot is declared NOT MEASURED and replaced by the harness reading, which reproduced the card's 5 rows on base.
  • The report's open question (Fixes or Part of): keep Fixes #24562. This PR fixes the card's named mechanism whole. The fifth row's cause, a masked password read, is filed as Seed replay rewrites every seeded row that carries a password field on every boot: the existing row is read through engine.find, which masks the password, so isNoOpReplay compares the seed plaintext with the mask #24624 (p1, this lane), which carries that half.
  • Landing: not yet. At this reading 30 of the 33 check-runs on 7cad49c762 are queued or in progress, and the Actions queue is backed up. This seat flips the PR to ready and arms auto-merge once every check is green.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 16:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 3566c08 Oct 19, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24562-seed-replay-time-equality branch October 19, 2026 17:19
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

2 participants