fix(objectql): a seed row keeps its authored created_at on insert, as the replay already does - #21661
Conversation
… 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>
📓 Docs Drift Check1 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
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
ACCEPT — PR #21661 at head
|
Fixes #21646
Clause-②: no
What changed
sys_stamp_audit_insert(packages/objectql/src/plugin.ts,applyToRecord) now keeps an authoredcreated_at(?? now) on a seed write (ExecutionContext.seedReplay), as it already did underpreserveAudit:That one ternary is the only change to the stamp.
updated_at,created_by,updated_by,tenant_idand the whole update stamp behave exactly as before.preserveAuditis not touched.SEED_WRITE_EXECUTION_CONTEXTis read, not edited, so the seed context does not gainpreserveAudit. A bareisSystemcontext, REST and every other caller still stamp now.Where the hook reads
seedReplay(H1, measured: one detail of the suggested route changed)seedReplay. The stamp reads its flags fromhookCtx.session.buildSession(engine.ts) builds that object one field at a time:userId,organizationId,positions,accessToken,isSystem,actor,skipTriggers,skipAutomationsandpreserveAudit. It never copiesseedReplay, so a branch onsession.seedReplaywould readundefinedon every seed write.beforeInsertenvelope'sinput.optionsis the caller's own options bag. The HookContextinputPHASE contract inpackages/spec/src/data/hook.zod.tssays so: abefore*handler reads the caller's bag.engine.insertis the onlybeforeInsertdispatch site, and it setsopCtx.context = options?.context. SohookCtx.input.options.context.seedReplayis the seed context exactly as the seeder built it. The new helperisSeedReplayreads it there.seedReplayintobuildSession. That would create a key the engine produces but no contract declares. Fixing that would need a new key onHookContextSchema.sessioninpackages/spec, which is a contract surface this card's Clause-② says it does not touch. This builtin would be the key's only reader.seedReplayis aNonEntryExecutionContextField(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.skipTriggersdoes not skip the audit hooks.triggerHooksreads onlysession.skipAutomations, and only to skip hooks bound from metadata.skipTriggersgates flow dispatch, never the code-registered audit hooks, and the seed context carries noskipAutomations. 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 assignscreated_byandupdated_byonly insideif (session?.userId), on both events, so it writes neither on a seed write. Seed writes areisSystem, so the readonly strip does not run on either path. The result:created_byis kept on the insert and on the replay update;created_bystays 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)
SeedLoaderServiceengine.insertMany/insert/updatewith{ context: SEED_WRITE_EXECUTION_CONTEXT }load()AppPluginreplaying a stack'sdata[]new SeedLoaderService(ql, …).load(); both fallbacks:ql.insert(object, record, SEED_WRITE_OPTIONS)per row@objectstack/verifyseed()ql.insert(object, rows, { context: SEED_CONTEXT })with an array, whereSEED_CONTEXT = SEED_WRITE_EXECUTION_CONTEXTAll three reach the stamp with
seedReplayset. There is no producer to fix. The othernew SeedLoaderService(…)sites (package install inprotocol.ts,runtime/src/domains/packages.ts, and the otherapp-plugin.tssites) are the same loader.H4:
celvalues (measured)SeedLoaderServiceevaluates every Expression envelope (resolveSeedRecord) before it decides insert or update. The insert and the update therefore both receive the evaluated instant, which is aDateforcel`daysAgo(5)`. Pin 1 reads it back as2026-09-28T00:00:00.000Zon both boots, the value the card measured on its replay.H5: the warning (measured)
preserveAuditIgnoredOnInsertWarningis emitted only from the non-isSystembranch ofengine.insert's create-side strip, and only whenpreserveAuditwas requested. A seed write isisSystemand has nopreserveAudit, 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 exactlypreserveAuditIgnoredOnInsertWarning('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 realObjectKernelwithObjectQLPlugin, so the audit hooks are bound the way a booted app binds them. It runs the realSeedLoaderService.load()twice over one store-backed stub driver: a fresh boot, then a replay.Dateis faked to two instants on the same UTC day.created_atis kept on the fresh boot (INSERT) and on the replay (UPDATE). Row A iscel`daysAgo(5)`and reads2026-09-28T00:00:00.000Z; row B is2026-09-01T12:00:00.000Z. The replay is a real update (totalUpdated: 3) and movesupdated_atto the second boot. A second case covers the call shapes of the other two readers.created_atis stamped at boot, and the replay leaves that stamp alone.{ isSystem }and{ isSystem, skipTriggers }) and a REST insert (the protocol'screateData, the doorPOST /api/v1/data/OBJECTuses) are all stamped now.preserveAuditinsert warning is unchanged, as described under H5.created_bymeasurement from H2.Pre-fix reading, the same file against unfixed
plugin.ts:Tests 2 failed | 4 passed (6). Both pin 1 cases fail withexpected '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 mergingorigin/main6ec54f00ba), unless a different commit is named: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-deferralandseed-loader-org-stamp. Result:Test Files 5 passed (5),Tests 24 passed (24).vitest run --project local --maxWorkers=2):Test Files 370 passed (370),Tests 7439 passed (7439).--project repo, one file):Tests 5 passed (5), read at2a8264570c.pnpm --filter @objectstack/objectql typecheck: exit 0. This coverstscover src and scripts, pluscheck:test-typecheckover the test layer, which reported40 file(s) / 234 error(s) / 65 pinned signature(s) held. The ledger is unchanged.--listFilesOnlyshows that program includes the new test file.Acceptance notes
created_atis now stored on the first insert. Before this change it was stored only on the replay update. Measured throughSeedLoaderService.load()over the kernel's engine: a literal'yesterday'on an author-declaredreadonlydatetime, and oncreated_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 rawcel`…`envelope, which only a writer that skipsresolveSeedRecordcan send (AppPlugin's two fallbacks,verify.seed()), is stored the same way. On the update path the envelope was already stored forcreated_atbefore this change. The cause is outside this card: a system-context write does not check the value shape ofreadonlyfields. That is reported to the seat as a separate finding, not fixed here.preserveAuditstill say "symmetric with howcreated_at/created_by(already) behave on insert": theExecutionContext.preserveAuditTSDoc and theHookContext.session.preserveAuditTSDoc. That has not been true sincecreated_atandcreated_bystopped being client-preferred on an ordinary insert. This is older drift, outside this card, and not fixed here.seedReplayTSDoc inexecution-context.zod.tslists what the flag exempts, which is only thestate_machinerule. It does not mention that the audit stamp now keeps an authoredcreated_atunder 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.mdxalready showscreated_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
seedReplayarm. It went throughscripts/ablation-replace.mjs, whose anchor must hit, and a shelltrapalso rangit checkout HEAD --on the absolute path. Predicted direction: pin 1 red, everything else green.Observed at
e5a2555da6, and identically at2a8264570c:e34d4989074d→4645b78e8872.Tests 2 failed | 4 passed (6). Both pin 1 cases failed withexpected '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.e34d4989074d),git diff HEADis 0 bytes, andgit status --porcelainis empty.No
dist/build is in this loop. The pin file imports./plugin.jsby relative path, so vitest reads the mutation fromsrc/.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run with no paths ate5a2555da6, 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--ranreconciliation reports64 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-limitfailed on the first run because the new stub driver'sfindignoredlimit.f135b5c542fixes that:findnow appliesast.limitafter the filter.pnpm lintalso runs only in CI.origin/maingained 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