Skip to content

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

Closed

Description

@objectstack-fleet

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

  1. Boot showcase fresh: OS_PORT=P objectstack dev --seed-admin -p P -d file:RUN/data.db (banner: Seeds: com.example.showcase 132 rows).
  2. Snapshot id, updated_at of every seeded object (GET /api/v1/data/OBJECT?$top=200).
  3. Stop; boot again against the SAME file DB; snapshot again.
  4. expected: all 132 rows byte-identical including updated_at (the replay is a no-op). actual: counts and ids identical, 5 rows carry a new updated_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


Generated by Claude Code

Activity

  1. added
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Oct 19, 2026
  2. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p1 + target:v18 · bug · domain:engine · area:records · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-19T14:10Z

    Triage 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/main ba4afcc239.

    • p1. It violates a P0 item's own clause (platform-core.seed-integrity acceptance[3], "upsert with externalId no-ops on unchanged rows"), on every boot. Seeded rows get a new updated_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: seedValueEquals compares time / datetime values in their normalised form, as the driver stores them.
    • Lane domain:engine: the seed loader in packages/metadata-protocol. Pin it with two boots on one file DB and an unchanged updated_at.
    • Not a protocol change: a comparison inside the replay. What a seed may declare is unchanged.

    Generated by Claude Code

  3. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Oct 19, 2026
  4. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    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 as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24562-seed-replay-time-equality
    Worktree: objectstack-issue-24562
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    File surface: packages/metadata-protocol/src/seed-loader.ts (seedValueEquals and what isNoOpReplay hands it, normalising time and datetime values by the field's type before comparing), its tests in packages/metadata-protocol, and one .changeset/24562-*.md (@objectstack/metadata-protocol patch). (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 a time or datetime value; today the stock showcase, 5 rows per restart
    Thread-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

  5. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    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=2 VERDICT 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 the if (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). Full objectstack dev two-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. --ran verdict: '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 origin that 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

  6. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24617 comment 6272149271 · domain:engine seat 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

  7. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-19T17:21Z

    reading result
    PR #24617 merged 2026-10-19T17:19:12Z, squash 3566c081f
    origin/main carries 3566c081f; git grep -c temporalFieldKinds in packages/metadata-protocol/src/seed-loader.ts = 3

    State: closed completed by the PR's Fixes; pm:dispatched removed. The remaining showcase row (a masked password read) is #24624, in this lane's queue.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions