Repository navigation
Seed replay rewrites unchanged rows on every boot: seedValueEquals compares time and ISO-datetime strings literally, so 09:15 vs 09:15:00 and …Z vs ….000Z bump updated_at (5 showcase rows per restart) #24562
Description
Filing gate: ① — product defect with a named location and a reproduction (QA-measured).
Path: platform-core.seed-integrity (priority P0) — acceptance[3] states "upsert with externalId no-ops on unchanged rows"; its count oracle cannot see this, so the clause scored pass while its parenthetical is violated (tri-state ②: the item asserts the capability, its steps do not reach the path).
Type: Bug (prefilled — violates isNoOpReplay's own documented behaviour, quoted below)
QA-source: #24557 · platform-core.seed-integrity · acceptance[3]
reach: measured once on a public boot — two objectstack dev boots of the stock showcase against the same file DB: 5 seeded rows whose seed values are unchanged get a new updated_at on the second boot (read via GET /api/v1/data/showcase_field_zoo, /showcase_invoice_line, /showcase_invoice), at framework 5b14b7f.
What happens
Every dev-server restart / package re-publish re-writes seeded rows whose declared values did not change, whenever the seed spells a time or datetime value differently from the driver's stored form. On stock showcase that is 5 rows per boot: "Specimen — Full" (field zoo), the invoice lines "INV-1001 · Consulting hours" and "INV-1004 · Consulting hours", and their parent invoices INV-1001 and INV-1004. Counts and ids are unchanged (no duplication); updated_at is bumped, lifecycle validation re-runs, and history tracking can log a phantom edit — exactly what the no-op skip exists to prevent.
Control: "Specimen — Minimal", authored with a full '09:05:30', does not churn.
Reproduction
- Boot showcase fresh:
OS_PORT=P objectstack dev --seed-admin -p P -d file:RUN/data.db(banner:Seeds: com.example.showcase 132 rows). - Snapshot
id, updated_atof every seeded object (GET /api/v1/data/OBJECT?$top=200). - Stop; boot again against the SAME file DB; snapshot again.
- expected: all 132 rows byte-identical including
updated_at(the replay is a no-op). actual: counts and ids identical, 5 rows carry a newupdated_at.
Mechanism
packages/metadata-protocol/src/seed-loader.ts, isNoOpReplay → seedValueEquals: dates are normalised only when one side is a Date instance; ISO strings and time strings fall through to String(a) === String(b). So the seed's '09:15' ≠ stored '09:15:00', and '…T…Z' ≠ stored '….000Z', and the row is rewritten.
The method's own docblock declares the intended behaviour: "When nothing the seed declares actually differs, rewriting the row is pure churn: updated_at gets bumped every boot, lifecycle validation re-runs …, and history tracking logs a phantom edit. Skip those." Its "any doubt reads as changed" escape covers unparseable values, not two spellings of the same instant.
Suggested permanent test: seed-mode-matrix's replay negative with a time and a datetime field on the scratch object.
Filing-gate answers
- Category: ① (finding class (a): reproducible defect; reach measured above).
- Acting reader:
domain:engineseat dev — a fix inpackages/metadata-protocol/src/seed-loader.ts(seedValueEquals: normalise ISO datetime and time-of-day strings by field type before comparing) plus a replay pin. - Duplicate search (open AND closed), objectstack: "seed replay rewrites unchanged rows updated_at bumped every boot isNoOpReplay time datetime format" → 6 hits (A seed row's explicit
created_atis overwritten with the boot instant on INSERT (seed context sets nopreserveAudit), yet written on the upsert UPDATE of a later boot — seeds cannot backdate creation time consistently #21646, Three provenance-stamp beforeUpdate hooks still carry the pre-#6966 "no input.id ⇒ bulk write" guard and re-read a row the engine already bound #15302,DbJobAdapter.replay()writes its syntheticsys_job_runrow regardless ofrecordRuns— an operator who switched run history off still accumulates replay rows #9633, Field.time repeats the #3912 pattern: writes unnormalised, repaired only on read — window filters and ORDER BY are silently wrong on SQLite #3994, seed replay vs state_machine initialStates: mid-lifecycle fixture rows are silently rejected on INSERT (showcase kanban can never repopulate) #3433, seed-loader: mode:'insert' datasets duplicate on every replay boot — showcase memberships 3→6→9 (needs composite externalId or explicit key) #3434 — none this defect; Field.time repeats the #3912 pattern: writes unnormalised, repaired only on read — window filters and ORDER BY are silently wrong on SQLite #3994 is the adjacent time-write normalisation).
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 19, 2026
Triage: p1 +
target:v18·bug·domain:engine·area:records·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T14:10ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. The 18.0 QA sweep found this at 5b14b7f (or the hotcrm next.5 bump). Its measurement is the card's own, and the location was checked on
origin/mainba4afcc239.platform-core.seed-integrityacceptance[3], "upsert with externalId no-ops on unchanged rows"), on every boot. Seeded rows get a newupdated_at(5 on the showcase per restart). The count oracle scored the clause pass, so the checklist could not see it.target:v18: it rides 18.0 if ready, and does not gate (6261853191). The fix is S:seedValueEqualscomparestime/datetimevalues in their normalised form, as the driver stores them.domain:engine: the seed loader inpackages/metadata-protocol. Pin it with two boots on one file DB and an unchangedupdated_at.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 1 · 2026-10-19T14:47Z
Session:
session_01AHeUBbe6S3JRawcpbLejXD(the dispatching seat; the dev runs as this session's subagent, identified by the branch)Account:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24562-seed-replay-time-equalityWorktree:
objectstack-issue-24562Domain:
domain:engineSeat:
domain:engine#2(seat post #20966)File surface:
packages/metadata-protocol/src/seed-loader.ts(seedValueEqualsand whatisNoOpReplayhands it, normalisingtimeanddatetimevalues by the field's type before comparing), its tests inpackages/metadata-protocol, and one.changeset/24562-*.md(@objectstack/metadata-protocolpatch). (stop on breach; explain in the report)Container & model:
S,mode:subagent,model: TIER_DEFAULT(dispatch-gates --tier: no path-derived mandate; the comparison is by field type, a judgment call, so not the floor)Clause-②: no
Responsibility:
packages/metadata-protocol's seed loader (seedValueEquals) produces the churn | none — no path normalises a time or ISO-datetime spelling before the comparison | every boot that replays a seed holding atimeordatetimevalue; today the stock showcase, 5 rows per restartThread-read: 6269258538
Serial constraints cleared: none — no lane claim and no open PR touches
seed-loader.ts(every open PR's file list read this fire). Same-day churn: 3ae0b77 (#24502) landed in this file today.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24562,
"status": "done",
"branch": "claude/issue-24562-seed-replay-time-equality",
"pr": "#24617",
"session": "session_01AHeUBbe6S3JRawcpbLejXD — the dispatching PM session (this dev ran as its subagent)",
"premise_still_valid": true,
"summary": "isNoOpReplay now compares fields declared time, datetime or date in their STORED form: both sides go through temporalStorageForm (@objectstack/core), the rule SqlDriver and InMemoryDriver apply on write. The field kinds come from the object definition loadDataset already resolves (new temporalFieldKinds) and are passed explicitly, as a required parameter, to every isNoOpReplay caller: decideWriteAction (3 call sites) and writeRecord (self-ref path). Nothing a seed may declare and nothing any write stores changes. An unparseable value still reads as changed. Mechanism confirmed on base with a real ObjectQL + SqlDriver on one SQLite file DB: '09:15' is stored '09:15:00', '…T14:30:00Z' is stored '….000Z', and the date kind has the same gap (an instant on a date field is stored YYYY-MM-DD), so it is covered too. Card premise valid, with one misattribution: of the card's 5 churning showcase rows, 4 stop churning (the two consulting-hours lines, and their invoices via summary recompute). Specimen — Full keeps churning after this fix from a second, independent mechanism: its f_password is masked to '••••••••' on every engine.find, so the seed plaintext never equals it (see out_of_scope_findings). PR keeps 'Fixes #24562' as dispatched, because the card's named mechanism is fully fixed; see open_questions.",
"tests": "At final head 7cad49c (after merging origin/main 5a871e4 and rebuilding the metadata-protocol closure):
pnpm --filter @objectstack/metadata-protocol typecheck && pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2VERDICT command-exit 0. typecheck clean; Test Files 248 passed | 3 skipped (251); Tests 20731 passed | 19 skipped. tsconfig include src//*, and --listFiles counts the test file. New pins in src/seed-loader-replay.test.ts (13 tests = 5 existing + 8 new) reuse the file's pinned faithful engine double, with insert/update overridden to store temporal fields in the driver form and stamp updated_at. The 8 new tests: 4 replay negatives (batched decideWriteAction and sequential writeRecord, each in modes upsert and update) assert no update and a deep-equal store; 2 positive controls assert only the changed rows are rewritten; 2 assert unparseable values still read as changed. Each asserts which path ran via a writeRecord spy. ABLATION (one-shot, fix committed at 09b324c, scripts/ablation-replace.mjs; tests import ./seed-loader.js from src, so no dist is involved): deleting theif (kind)stored-form block took anchor 1 to 0 and blob ee3eb38f87de to 206fd67db529. Predicted all 8 new red and 5 existing green; observed 8 failed | 5 passed (replay negatives: update called 4 times). Restored to blob == HEAD ee3eb38f87de with git diff HEAD empty. REAL-DRIVER measurement (scratch harness, not committed; real ObjectQL + SqlDriver better-sqlite3, one file DB, fresh driver/engine/loader per boot): on base 2667e24, boot2 updated=3 skipped=1; after the fix (09b324c dist), boot2 updated=0 skipped=4, and boot3 with time + instant changed gave updated=2 (exactly those rows). Showcase harness (all examples/app-showcase object definitions + every ShowcaseSeedData dataset on a showcase-owned object, 127 rows): on base, boot2 engine.update hit the card's exact 5 rows; after the fix, 1 row (Specimen — Full, f_password only). Fullobjectstack devtwo-boot: NOT MEASURED, reason: the CLI build closure is 63 packages, too heavy for the shared verify lock; the harness above substitutes for it.","gates": "Local:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) at 7cad49c derived 64 commands; every one run with its exit code captured before any pipe.--ranverdict: '64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED', exit 0. All 64 exit 0, including check:engine-double-contract (OK — 1031 pinned), check:durability-log-level, check:nul-bytes (OK), check:type-check-debt (OK), check:dual-build-cjs-loads (111 entry points load), check-adr-0087-registration (1 non-breaking changeset), check:published-files. check-plugin-teardown-shape --self-test needed its pinned positive-control commit 621a487 fetched (git fetch --depth=1 originthat sha) on the shallow clone, then 48 cases pass. An earlier pass at f2d64ae (pre-merge) showed the same, with dual-build-cjs-loads exit 3 PREREQUISITE NOT MET before the full dist existed. test:repo and whole-repo lint: declared to CI. CI on PR head 7cad49c: all 31 check-runs queued at report time (Test Core 1-6/6, Build Core, Type Check ×4, Lint ×8, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, the claim/single-writer guards and others): in_progress, not yet read as a verdict.","mcp_calls": "0",
"api_writes": "2 — (1) pr_create through the fleet relay: POST /repos/objectstack-ai/objectstack/dispatches (204), executed as POST /repos/objectstack-ai/objectstack/pulls (draft, PR 24617) + POST /repos//issues/24617/assignees ['os-warren']; read back: author objectstack-fleet[bot], draft, assignee os-warren, body byte-identical. The dispatch tool reported UNCONFIRMED (relay run 39284032443 still queued after 300 s), so the target was read back rather than re-sent. (2) this os-dev-report comment: relay dispatch, executed as POST /repos//issues/24562/comments. git push is not a REST write.",
"files_changed": [
"packages/metadata-protocol/src/seed-loader.ts",
"packages/metadata-protocol/src/seed-loader-replay.test.ts",
".changeset/24562-seed-replay-time-equality.md"
],
"line_budget": "n/a — no skills/ or governed-ledger path touched; diff +245 / -15 over 3 files, under the 3000-line threshold",
"deviations": [
"Fetched one pinned commit (621a487, --depth=1) into the shared shallow object store so check-plugin-teardown-shape --self-test could read its positive control; this adds one shallow graft and is read-only for every other worktree.",
"Merged origin/main (5a871e4, 2 commits, no conflict, no seed-loader change) before opening the PR per AGENTS §10, then re-ran the package typecheck/tests and the full derived gate set on the merged head.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer; commits carry the model-free pair AGENTS.md prescribes (Claude-Session + Co-authored-by: Claude). Reported, history not rewritten."
],
"open_questions": [
{
"question": "Keep 'Fixes #24562' on PR 24617 or switch to 'Part of'? After this fix, 1 of the card's 5 named showcase rows (Specimen — Full) still churns, from a different mechanism (masked password read, below).",
"options": [
"A keep Fixes: the card's title mechanism (literal time/datetime comparison) is fully fixed and pinned; the password churn gets its own card",
"B switch to Part of: the card stays open until Specimen — Full stops churning too; the seat rewrites the body's first line"
],
"recommendation": "A. The card's title and mechanism section name only the temporal comparison, which this PR fixes completely. The f_password churn has a separate cause, and fixing it needs a decision of its own, so it is better tracked as its own card than as an open remainder here."
}
],
"out_of_scope_findings": [
"class: a · reach: public boot — the card's own showcase two-boot measurement: after this PR, 'Specimen — Full' (showcase_field_zoo) still gets a new updated_at on every boot; measured in the harness at 09b324c with real ObjectQL + SqlDriver and the showcase's own field-zoo definition and seed: boot2 engine.update hit exactly that row, and the only key read as changed was f_password (seed 'hunter2' vs stored '••••••••') · evidence: SeedLoaderService.loadExistingRecords reads existing rows through engine.find with context isSystem; ObjectQL.maskSecretFields masks every password field on a generic object to SECRET_MASK in EVERY context (packages/objectql/src/engine.ts maskSecretFields, collectMaskedReadFields), so isNoOpReplay compares plaintext with the mask and any seeded row carrying a password field is rewritten on every replay (updated_at bumped, lifecycle validation and history re-run). That violates platform-core.seed-integrity acceptance[3] the same way. A fix needs a decision: compare the credential through a privileged read, treat a masked field as not comparable (it never blocks a skip, so a changed seed password is never re-applied), or keep it rewriting. · Seam: spec:Field.password (masked on read) → runtime:SeedLoaderService.isNoOpReplay via loadExistingRecords → ObjectQL.find maskSecretFields · dedupe words: seed replay password masked updated_at churn; isNoOpReplay SECRET_MASK; maskSecretFields seed loader existing row; Specimen Full updated_at every boot"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
ACCEPT → PR #24617 comment 6272149271 ·
domain:engineseat 2 ·session_01AHeUBbe6S3JRawcpbLejXD· 2026-10-19T16:40Z. Lands when every check on the PR head is green. The fifth showcase row's cause (a masked password read) is #24624, which carries that half. The docs-drift bot's two named pages (validation.mdx,state-machine.mdx) state the seed state-machine exemption, not replay equality, so they are not affected.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed ·
domain:engineseat 2 ·session_01AHeUBbe6S3JRawcpbLejXD· 2026-10-19T17:21Z3566c081forigin/main3566c081f;git grep -c temporalFieldKindsinpackages/metadata-protocol/src/seed-loader.ts= 3State: closed
completedby the PR'sFixes;pm:dispatchedremoved. The remaining showcase row (a masked password read) is #24624, in this lane's queue.Generated by Claude Code