Skip to content

fix(objectql): a seed row keeps its authored created_at on insert, as the replay already does - #21661

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21646-seed-created-at
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21646-seed-created-at

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21646
Clause-②: no

  • An authored seed value that was silently dropped on insert is now kept, as the replay already keeps it.
  • No accepted input, key, export or error code is added or removed.
  • No contract surface is touched.

What changed

sys_stamp_audit_insert (packages/objectql/src/plugin.ts, applyToRecord) now keeps an authored created_at (?? now) on a seed write (ExecutionContext.seedReplay), as it already did under preserveAudit:

record.created_at = preserveAudit || seedReplay ? (record.created_at ?? now) : now;

That one ternary is the only change to the stamp. updated_at, created_by, updated_by, tenant_id and the whole update stamp behave exactly as before. preserveAudit is not touched. SEED_WRITE_EXECUTION_CONTEXT is read, not edited, so the seed context does not gain preserveAudit. A bare isSystem context, REST and every other caller still stamp now.

Where the hook reads seedReplay (H1, measured: one detail of the suggested route changed)

  • The hook session has no seedReplay. The stamp reads its flags from hookCtx.session. buildSession (engine.ts) builds that object one field at a time: userId, organizationId, positions, accessToken, isSystem, actor, skipTriggers, skipAutomations and preserveAudit. It never copies seedReplay, so a branch on session.seedReplay would read undefined on every seed write.
  • The flag is read from the options bag. The beforeInsert envelope's input.options is the caller's own options bag. The HookContext input PHASE contract in packages/spec/src/data/hook.zod.ts says so: a before* handler reads the caller's bag. engine.insert is the only beforeInsert dispatch site, and it sets opCtx.context = options?.context. So hookCtx.input.options.context.seedReplay is the seed context exactly as the seeder built it. The new helper isSeedReplay reads it there.
  • Not taken: copying seedReplay into buildSession. That would create a key the engine produces but no contract declares. Fixing that would need a new key on HookContextSchema.session in packages/spec, which is a contract surface this card's Clause-② says it does not touch. This builtin would be the key's only reader.
  • Trust is the same on both routes. seedReplay is a NonEntryExecutionContextField (packages/core/src/security/assemble-execution-context.ts), so no transport entry point builds it from a request. The session is also built from that same context object.
  • skipTriggers does not skip the audit hooks. triggerHooks reads only session.skipAutomations, and only to skip hooks bound from metadata. skipTriggers gates flow dispatch, never the code-registered audit hooks, and the seed context carries no skipAutomations. Pin 2 shows this: an unauthored seed row is stamped by the hook at boot.

H2: created_by (measured, no change)

The seed context carries no userId. The stamp assigns created_by and updated_by only inside if (session?.userId), on both events, so it writes neither on a seed write. Seed writes are isSystem, so the readonly strip does not run on either path. The result:

  • an authored created_by is kept on the insert and on the replay update;
  • an unauthored created_by stays absent.

Both paths already behaved the same before this change. A companion test records the measurement, labelled as green on both sides of the change.

H3: three readers, one context (measured)

reader call covered by
SeedLoaderService engine.insertMany / insert / update with { context: SEED_WRITE_EXECUTION_CONTEXT } pins 1, 2 and 4, through load()
AppPlugin replaying a stack's data[] main path: new SeedLoaderService(ql, …).load(); both fallbacks: ql.insert(object, record, SEED_WRITE_OPTIONS) per row pin 1 (loader) and pin 1's single-row case
@objectstack/verify seed() ql.insert(object, rows, { context: SEED_CONTEXT }) with an array, where SEED_CONTEXT = SEED_WRITE_EXECUTION_CONTEXT pin 1's array case

All three reach the stamp with seedReplay set. There is no producer to fix. The other new SeedLoaderService(…) sites (package install in protocol.ts, runtime/src/domains/packages.ts, and the other app-plugin.ts sites) are the same loader.

H4: cel values (measured)

SeedLoaderService evaluates every Expression envelope (resolveSeedRecord) before it decides insert or update. The insert and the update therefore both receive the evaluated instant, which is a Date for cel`daysAgo(5)` . Pin 1 reads it back as 2026-09-28T00:00:00.000Z on both boots, the value the card measured on its replay.

H5: the warning (measured)

preserveAuditIgnoredOnInsertWarning is emitted only from the non-isSystem branch of engine.insert's create-side strip, and only when preserveAudit was requested. A seed write is isSystem and has no preserveAudit, so the warning cannot fire for it, before or after this change. Pin 4 checks both halves. Two seed boots produce no line with the warning's lead clause. A non-system { userId, preserveAudit: true } create produces exactly preserveAuditIgnoredOnInsertWarning('seed_case', ['run_at']). The expected line comes from the producer function, not from a copy of its text.

Tests

New: packages/objectql/src/plugin-audit-seed-created-at.test.ts. It boots a real ObjectKernel with ObjectQLPlugin, so the audit hooks are bound the way a booted app binds them. It runs the real SeedLoaderService.load() twice over one store-backed stub driver: a fresh boot, then a replay. Date is faked to two instants on the same UTC day.

  • Pin 1. An authored created_at is kept on the fresh boot (INSERT) and on the replay (UPDATE). Row A is cel`daysAgo(5)` and reads 2026-09-28T00:00:00.000Z; row B is 2026-09-01T12:00:00.000Z. The replay is a real update (totalUpdated: 3) and moves updated_at to the second boot. A second case covers the call shapes of the other two readers.
  • Pin 2. A seed row with no created_at is stamped at boot, and the replay leaves that stamp alone.
  • Pin 3. A non-seed system insert ({ isSystem } and { isSystem, skipTriggers }) and a REST insert (the protocol's createData, the door POST /api/v1/data/OBJECT uses) are all stamped now.
  • Pin 4. The preserveAudit insert warning is unchanged, as described under H5.
  • Companion. The created_by measurement from H2.

Pre-fix reading, the same file against unfixed plugin.ts: Tests 2 failed | 4 passed (6). Both pin 1 cases fail with expected '2026-10-03T12:00:00.000Z' to be '2026-09-28T00:00:00.000Z', which is the card's table: the boot instant replaced the authored value.

Readings at e5a2555da6 (the head after merging origin/main 6ec54f00ba), unless a different commit is named:

  • The pin file and its four nearest neighbours, run with pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2: plugin-audit-seed-created-at, plugin-audit-created-at-create-side, plugin-audit-created-by-create-side, engine-seed-required-deferral and seed-loader-org-stamp. Result: Test Files 5 passed (5), Tests 24 passed (24).
  • The whole local project (vitest run --project local --maxWorkers=2): Test Files 370 passed (370), Tests 7439 passed (7439).
  • The repo project (--project repo, one file): Tests 5 passed (5), read at 2a8264570c.
  • pnpm --filter @objectstack/objectql typecheck: exit 0. This covers tsc over src and scripts, plus check:test-typecheck over the test layer, which reported 40 file(s) / 234 error(s) / 65 pinned signature(s) held. The ledger is unchanged. --listFilesOnly shows that program includes the new test file.

Acceptance notes

  • Under the seed context, a malformed authored created_at is now stored on the first insert. Before this change it was stored only on the replay update. Measured through SeedLoaderService.load() over the kernel's engine: a literal 'yesterday' on an author-declared readonly datetime, and on created_at, is stored verbatim with no error. The same literal on a non-readonly datetime is refused (must be a valid datetime (ISO-8601)) and reported as a seed error. A raw cel`…` envelope, which only a writer that skips resolveSeedRecord can send (AppPlugin's two fallbacks, verify.seed()), is stored the same way. On the update path the envelope was already stored for created_at before this change. The cause is outside this card: a system-context write does not check the value shape of readonly fields. That is reported to the seat as a separate finding, not fixed here.
  • Two descriptions of preserveAudit still say "symmetric with how created_at / created_by (already) behave on insert": the ExecutionContext.preserveAudit TSDoc and the HookContext.session.preserveAudit TSDoc. That has not been true since created_at and created_by stopped being client-preferred on an ordinary insert. This is older drift, outside this card, and not fixed here.
  • The seedReplay TSDoc in execution-context.zod.ts lists what the flag exempts, which is only the state_machine rule. It does not mention that the audit stamp now keeps an authored created_at under it. Its statements are still true, so this change does not make them false. The file is outside this card's surface.
  • content/docs/data-modeling/seed-data.mdx already shows created_at: cel`now()` in a seed record. That value is now kept on insert as well as on replay. No doc text changes.

Reverse verification

The fix was committed first. The reverse leg then reverted only the stamp's seedReplay arm. It went through scripts/ablation-replace.mjs, whose anchor must hit, and a shell trap also ran git checkout HEAD -- on the absolute path. Predicted direction: pin 1 red, everything else green.

Observed at e5a2555da6, and identically at 2a8264570c:

  • The mutation landed. The anchor count went 1 → 0 and the reverted form 0 → 1. The blob went e34d4989074d → 4645b78e8872.
  • Only pin 1 went red. Result: Tests 2 failed | 4 passed (6). Both pin 1 cases failed with expected '2026-10-03T12:00:00.000Z' to be '2026-09-28T00:00:00.000Z' and … to be '2026-09-01T12:00:00.000Z'. Pins 2, 3 and 4 and the companion stayed green.
  • The file was restored. The blob after restore equals the HEAD blob (e34d4989074d), git diff HEAD is 0 bytes, and git status --porcelain is empty.

No dist/ build is in this loop. The pin file imports ./plugin.js by relative path, so vitest reads the mutation from src/.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run with no paths at e5a2555da6, derives 64 commands for this change set. That is the dispatch list's 50 plus 14 more:

  • check-adr-0087-registration (twice);
  • check-empty-changeset (twice);
  • 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 64 ran at e5a2555da6, with each exit code captured before any pipe: 64 × exit 0. The --ran reconciliation reports 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3).

  • check:objectql-double-limit failed on the first run because the new stub driver's find ignored limit. f135b5c542 fixes that: find now applies ast.limit after the filter.
  • NOT MEASURED locally: the families the derivation lists outside its total, which run only in CI. These are shard attestation, test completeness, the Test Core, Dogfood, Temporal Conformance and Build Core jobs, and the workspace type-check lanes. The repository-wide pnpm lint also runs only in CI.
  • origin/main gained one more commit after the merge: eea82af677, metadata-protocol's view-container save door. None of its files is in this diff.

Generated by Claude Code

claude added 3 commits October 4, 2026 00:42
… the replay already does

The built-in beforeInsert audit stamp kept a supplied created_at only under
preserveAudit, so a seed write (SEED_WRITE_EXECUTION_CONTEXT, which carries
seedReplay and no preserveAudit) had its authored created_at replaced with
the boot instant on insert, while the upsert update of a later boot wrote
the authored value. The insert stamp now keeps an authored created_at under
seedReplay too, read from the beforeInsert envelope's input.options.context
(the hook session carries no seedReplay). Bare isSystem, REST and every
other caller still stamp now; preserveAudit and its insert warning are
unchanged.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ler's limit

check:objectql-double-limit flagged the new stub driver's find as
limit-blind; it now applies ast.limit after the filter, by presence.

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 4, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 — 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 eea82af67779f504bd5d53fccda3151066cdeaf6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json eea82af67779f504bd5d53fccda3151066cdeaf6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21661 at head e5a2555da6

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T01:34Z. The os-dev report is on #21646. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
    • The first lines are Fixes #21646 and Clause-②: no.
    • The closing-keyword scan finds #21646 only.
  • Scope: 3 files, +388/-5: plugin.ts, a new pin file and the changeset. check-governed-merges reports NOT governed, and no packages/spec file is touched.
  • The diff, read:
    • sys_stamp_audit_insert now keeps an authored created_at (?? now) when the write is a seed write.
    • The update stamp passes false, and it never writes created_at anyway.
    • preserveAudit's arm, its insert warning and SEED_WRITE_EXECUTION_CONTEXT are unchanged.
    • Bare isSystem, REST and every other caller still stamp now. That is the ruling (5974766961) to the letter.
  • H1's falsified detail — accepted, and the trust boundary checked by the seat:
    • The hook session that buildSession builds carries no seedReplay. So the stamp reads input.options.context.seedReplay, the write's own ExecutionContext.
    • That channel is server-built:
      • assemble-execution-context.ts lists seedReplay among the fields no transport entry point resolves ("per-write behaviour flags, server-constructed at the call site");
      • the protocol's data door deletes any inbound context and forwards only the dispatcher's (protocol.ts:12064, :12083).
    • It is the same channel that carries isSystem, so a path that could forge seedReplay could already forge the stronger flag. The engine already reads ctx.seedReplay from it to skip the state machine (engine.ts:2917).
    • Not declaring a new HookContext.session key keeps the claim's Clause-②: no honest.
  • H2 to H5 — accepted:
    • created_by is never stamped on a seed write (no user in the context), so an authored value is kept on both paths, as before.
    • All three seeders pass the one constant.
    • cel values resolve before the insert/update decision.
    • The warning cannot fire for a seed write.
  • Changeset, checked sentence by sentence:
    • patch for @objectstack/objectql and Clause-②: no.
    • "Before" matches the card's table.
    • "After" matches the new arm and the unchanged update stamp.
    • "Unchanged" matches as well: REST, bare isSystem, preserveAudit and its warning, and created_by.
    • "No schema, key, export or error code is added or removed" matches the diff.
  • Evidence:
    • The pins run the real SeedLoaderService.load() twice over one store. Before the fix, exactly the two pin-1 cases are red with the card's values. After it, all 6 pass.
    • Reverse verification of the seedReplay arm turns exactly those two red, and the restore was proved by blob equality.
    • The objectql local suite passes 7439 of 7439, typecheck exits 0, and dispatch-gates --ran reconciles 64 of 64. check:objectql-double-limit was red once and fixed in the stub driver.
  • CI on e5a2555d, at this read: 14 check runs are in progress. The seat lands only once every check is green or an expected skip.

Out-of-scope:

  • Filed by the seat (class a): a system-context write skips value-shape validation for readonly fields, so a seed's malformed readonly datetime is stored verbatim. Before this PR it was stored on replay only; it now reaches the insert path too. This PR keeps an authored value as the ruling asks, and the validation gap is its own card.
  • Noted, not filed: two spec TSDocs (ExecutionContext.preserveAudit and HookContext.session.preserveAudit) still call preserveAudit "symmetric with how created_at / created_by behave on insert". That is older drift, recorded in 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

2 participants