fix(objectql): stamp created_at from the system clock on an ordinary create, so a caller cannot forge the audit anchor through a plain POST - #16313
Conversation
The audit binder's beforeInsert stamp used `record.created_at ?? now`, which since #15395 launders a caller-supplied value past the engine-side static readonly strip (#14259 reads a key a hook ASSIGNED as the hook's write). It now takes the same shape as `updated_at`: the system clock wins unless `preserveAudit` is set, which is the historical-import channel and stays. Ruled by the maintainer 2026-09-06, decision batch #54, option A. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…rveAudit control Reproduces the card's four-field table through the real ingress (kernel + ObjectQLPlugin + engine.insert), with `id` / `run_at` / `updated_at` as the in-experiment controls, and pins that `preserveAudit` still reinstates an original created_at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lot-lookup) The new test file is not grandfathered in the slot-lookup baseline, so the kernel service lookup takes the slot's contract type instead of `as any`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 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 — 16 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 d424f88d7bc67ab3d8ad87422a398db1277992da && git checkout d424f88d7bc67ab3d8ad87422a398db1277992da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e270d4e296368f6600d71fcec9902f3a14c1698 2bb64a7bf4d68d90d346933197e7644a67d6c470 && git checkout -B drift-repro 3e270d4e296368f6600d71fcec9902f3a14c1698 && git merge --no-ff 2bb64a7bf4d68d90d346933197e7644a67d6c470
node scripts/docs-audit/affected-docs.mjs --json 3e270d4e296368f6600d71fcec9902f3a14c1698 |
CI is red on
|
…eAudit The analytics timezone fixture back-dates `created_at` to a deliberate DST boundary and was relying on the create-side `??` that #15964 removes — the first measured LEGITIMATE consumer of that hole. It now uses the explicit historical channel the same ruling preserved (`preserveAudit`, what REST's `treatAsHistorical` sets), and every assertion in the file is byte-unchanged. `isSystem` alone never preserved it: that flag exempts the engine's readonly strip, not the audit binder's stamp. Both halves are pinned at unit level in `plugin-audit-created-at-create-side.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ssue-15964-created-at-unconditional-stamp
Fixes #15964
Maintainer ruling of 2026-09-06 (director seat, decision batch #54), verbatim 「同意」 — recommendation A adopted: the audit binder's
beforeInsertstamp forcreated_attakes the same shape asupdated_at, so the system clock wins on an ordinary create and a caller-supplied value is stripped-by-overwrite. This PR is that one-line shape change, its pin, and the enumeration the ruling asked for.The change
packages/objectql/src/plugin.ts, the audit binder'sbeforeInsertstamp:which is now byte-symmetric with the line under it,
record.updated_at = preserveAudit ? (record.updated_at ?? now) : now;.Why the
preserveAuditternary and not a bare= now. The ruling asks for two things at once — the ordinary create closed, and the historical-import channel kept — and only this shape gives both.treatAsHistoricalreachescreated_atthrough this very hook: it setspreserveAudit: trueon the write context (packages/rest/src/import-runner.ts:448) and has no separate path to the audit stamp. A bare= nowwould have closed the hole and broken the ruled channel in the same line.Reproduced FIRST on
origin/main, then again after — same test file, same head, one line apartBoth tables below are printed by the same pin test (
packages/objectql/src/plugin-audit-created-at-create-side.test.ts) run at headfe4d6cec0; the only difference is whetherplugin.tsis atorigin/main(7beaaa32c) or at this branch. (The branch has since mergedorigin/mainat3e270d4e2; the final head is2bb64a7bfand everything under Verification was re-run there.) The rig is the real ingress — anObjectKernelwithObjectQLPlugin, so the shippedsys_stamp_audit_inserthook is bound throughbindHooksToEngineandengine.insertruns the static-readonly strip after it — over a capturing driver, so what reachesdriver.createis the stored row.BEFORE (
plugin.tsatorigin/main; the pin fails on exactly one assertion):AFTER (this branch):
The three stripped rows are the card's own in-experiment controls and they are what makes this a reading rather than an anecdote: they prove the create-side strip IS running on this path and DOES take other author-declared
readonlydatetimes, socreated_atsurviving was "the strip ran and spared exactly this one". All three still strip after the change — a fix that closedcreated_atand opened one of them would be a regression on a security card.The ruled historical-import control, same file, both before and after:
run_atin that row is the second control: the preservation is the audit binder's, not the strip's. The 2026-08-08 ruling that madepreserveAuditUPDATE-only forstripReadonlyFieldsis untouched here — an ordinary readonly business column is still taken on the create side even under the flag.Ablation hygiene. The before-leg was taken with
plugin.tsbyte-identical toorigin/main(git hash-object==git rev-parse BASE:packages/objectql/src/plugin.ts, blobb261d3cf7), the mutation was proved on disk by whole-line anchored counts in both directions (new-line 0 / old-line 1) before anything was measured, and the restore was proved after (blob back toe3b3bdda7== the HEAD blob,git diff HEADempty). No rebuild is involved on either leg: the test imports./plugin.jsrelatively, so vitest resolves the mutated file from source, never through the package'sexportsanddist/.Other creators that relied on the create-side
??— the enumeration the ruling asked forOne exists, and it is filed as #16312:
packages/metadata/src/migrations/migrate-sys-notification-to-event.ts:185,197inserts the materialized inbox row and its receipt with the LEGACY notification'screated_at, through a realIDataEngine, passing no options bag — so nopreserveAuditand noisSystem. After this change those rows carry the migration instant instead of the original one. Its own suite stays green (23 passed) because it drives the migration through a fake engine double, where no before-phase hook runs — so nothing in CI would have said this out loud. Per the ruling that is a finding, not a reason to keep the??; the remedy there is one context key,{ context: { preserveAudit: true } }, the same channeltreatAsHistoricaluses.Method, and the rest of the sweep: every non-generated source under
packages/,apps/andexamples/carryingcreated_atas an object-literal key was classified by whether the value is the current instant (nothing to preserve) or an external/back-dated one (a real reliance).metadata/src/migrations/migrate-sys-notification-to-event.ts:185,197created_atmetadata/src/loaders/database-loader.ts:1357nowrest/src/rest-server.ts:8826new Date().toISOString()services/service-messaging/src/sql-outbox.ts:117,sql-http-outbox.ts:189nowservices/service-messaging/src/inbox-channel.ts:120,207,messaging-service.ts:783,1017services/service-automation/src/flow-dispatch-store.ts:71,suspended-run-store.ts:333,691nowobjectql/src/engine.ts:6741new Date().toISOString()— and it callssecretDriver.createdirectlyruntime/src/domains/share-links.ts:238A second consumer exists and CI found it, not my sweep — and it is the interesting one.
packages/qa/dogfood/test/analytics-timezone.dogfood.test.tsseeds threecrm_leadrows at a deliberate timezone-boundary instant (2024-03-01T03:00:00.000Z, which is still 2024-02-29 in America/Los_Angeles) to drive analytics date bucketing, and its own loud sanity check then reads them back. Atfe4d6cec0the Dogfood Regression Gate (3/3) went red on exactly that check:expected '2026-09-06T14:00:40.005Z' to be '2024-03-01T03:00:00.000Z', 1 failed of 44 files. Reproduced locally before anything was written, as the firing control that my rig really does see what CI saw: reverting ONLY the seeding context (assertion untouched, proved by anchored counts in both directions) givesexpected '2026-09-06T14:21:25.288Z' to be '2024-03-01T03:00:00.000Z'— the same assertion, the same shape. Re-applying the one-key adaptation turns it green, 2 passed.That is the first measured evidence that the old
??was load-bearing for a LEGITIMATE use — seeding historical analytics rows — rather than only for a forgery. It strengthens the card rather than weakening it: the create-side channel really was doing work, it was just doing it for everyone, unconditionally and with no flag, which is precisely why an unauthenticated-lookingPOSTcould reach it too.The decisive question was measured before anything was written: does the ruled explicit channel reach this seeding path? It does, both by source and by experiment.
ObjectQL.buildSession(packages/objectql/src/engine.ts:3722) propagatespreserveAuditfrom theExecutionContexton its own branch, unrelated toisSystem, so{ context: { isSystem: true, preserveAudit: true } }reaches this hook the same wayrunImport({ treatAsHistorical: true })does. So the fixture moves to the explicit channel and its assertion is untouched — stilltoBe(BOUNDARY), still exact, still loud.isSystemnever was the reason itscreated_atsurvived.isSystemexempts the engine's readonly STRIP; the audit binder's stamp is not gated on it at all. The seed was riding the??, not its own elevation. Both halves are now pinned at unit level inplugin-audit-created-at-create-side.test.ts— underisSystemalone,idandrun_atDO survive (the control proving the elevation really took effect) whilecreated_atandupdated_atare stamped; addpreserveAuditand the back-datedcreated_atlands.How my sweep missed it, stated rather than smoothed over. The table above covers non-test sources only, and the second pass I ran over test files keyed on
ObjectQLPlugin|defineStack|createStack|startStack|bootKernel— which omitsbootStack, the dogfood harness's boot verb. The fixture was outside my population twice. Re-run withbootStackincluded and over test files, the sweep now finds three more candidates and clears all three by measurement:storage-growth.dogfood.test.ts:152,158,231back-dates throughdriver.createdirectly, so no engine hook runs;attachments-permission-matrix.dogfood.test.ts:452is likewise a direct driver insert and:479back-dates throughql.update, which this change does not touch;oidc-authorization-code-flowandoidc-authorize-env-gateseednowIso. Consistent with CI, where the dogfood shard reports exactly one failure across 44 files.A third finding came out of the same drive and is filed as #16311:
created_byhas the identical laundering hole one field over —record.created_by = record.created_by ?? session.userIdis??while its siblingupdated_byis thepreserveAuditternary. Measured on the same rig: an authenticated caller sendingcreated_by: 'forged_user'had it stored whileupdated_byin the same payload was correctly overwritten with the session user; with nosession.userIdthe hook assigns nothing and the strip then takes the forgery correctly. The triage comment on #15964 raised exactly this and recorded it as untested; it is now measured. It is not changed here: the ruling adopted option A forcreated_atand named only that field, andcreated_by's shape has a second question of its own (it ishasField- and session-guarded, unlikecreated_at). #16311 is not addressed by this PR.Clause ②:
no— re-derived from the diff, not inheritedThe ruling's stated expectation was
no; declared at claim and then measured rather than carried over. Method: build at head, swap onlypackages/objectql/src/plugin.tsback toorigin/main, rebuild, and compare every declaration file the package publishes —dist/{index,core,util-zFBRz_yg}.d.tsand their.d.mtstwins — by both exported-name set and bytes.index.d.ts/index.d.mtscore.d.ts/core.d.mtsutil-zFBRz_yg.d.ts/.d.mtsThe rebuild is proved to have re-run rather than been skipped:
dist/index.jsmtime advanced1788702294 -> 1788702314 -> 1788702335across the three legs, and the declarations' own mtimes advanced with it while their hashes did not.The instrument is proved live, because an identical manifest is otherwise indistinguishable from a blind spot. A public member injected onto the exported
ObjectQLPluginclass moveddist/index.d.tsfrom blob5f44a2a8dto54e2b4ba4and appeared in the published declaration; restoring returned it to5f44a2a8dwith the member gone. (A first control aimed at a NEW top-levelexport constinplugin.tsdid not fire, and that is a fact about the barrel rather than a dead instrument:packages/objectql/src/index.ts:463re-exportsObjectQLPluginby name, so a new top-level export in that module reaches no published entry point. The control was re-aimed inside the published set.)So: no exported symbol moves, and the accept set only narrows, to the
readonlycontract the field already documents. A caller may still sendcreated_at; it is now ignored on an ordinary create rather than honoured.patch, per the repo rule that a bug fix in a released package takes a patch changeset.Verification
Everything here was re-run at the final head
2bb64a7bf, after mergingorigin/mainat3e270d4e2(the gate-family deriver had flagged the pre-merge tree as stale: 12 commits behind with 34 of the gate scripts themselves changed in that range, so no local gate reading taken on it would have been about a tree anyone is on). Exit codes were captured by redirect-then-read, never through a pipe.packages/objectql/src/plugin-audit-created-at-create-side.test.ts— 4 cases: the ordinary create with its three controls; a create sending nocreated_at;isSystemalone versusisSystem+preserveAudit; and thepreserveAudithistorical control.plugin.integration.test.ts,engine-audit-anchor-write,engine-insert-static-readonly-strip,engine-readonly-strip-caller-values,engine-readonly-strip-signal,engine-strict-readonly-warning-truthful,engine-hook-provenance-sibling-seams,engine-post-hook-undeclared-field,stamped-system-fields-spec-conformance,seed-loader-org-stamp,protocol-data.analytics-timezone.dogfood.test.ts, 2 tests, passing, driven end to end on a real booted CRM stack.@objectstack/restimport path — 3 files, 20 tests, all passing, includingimport-runner-historical-readonly-insert.test.ts, the pin that drivestreatAsHistoricalthrough the real insert ingress. (These two files first read as failures against an unbuilt@objectstack/objectql; that wasFailed to resolve entry for package, not an assertion.)pnpm --filter @objectstack/objectql typecheckandpnpm --filter @objectstack/dogfood typecheck— both clean, the first includingcheck:test-typecheck(44 files / 242 errors / 69 pinned signatures held; the new test file is inside the checked zone and owes no ledger entry).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(the deriver takes the change set from the merge base itself, so no hand-built diff can narrow it): 60 run, 60 green, nothing declared unmeasured.check:slot-lookupflagged the new test'sgetService('objectql') as anyas a NEW service-lookup erasure. It now takes the slot's contract type, and the ratchet is back at baseline (106 unswept sites in 25 files, none new).check:dual-build-cjs-loadsandcheck:type-check-debthad both been refusing with exit 3 (PREREQUISITE NOT MET) because they read built output for the whole workspace. A fullturbo run build --filter='./packages/*' --filter='./packages/*/*'(71/71 tasks, 68 packages emittingdist/) turned both into real measurements: dual-build sweeps 103 entries / 66 packages / 619 CJS files / 1 probe against floors of 90 / 58 / 520 / 1;check:type-check-debt --re-measurere-measures 5 ledger entries in 103.6s, 55 raw tsc errors total, none above its recorded number, surplus none. The re-measure needs more heap than the 4096 MB this seat runs commands under — it OOM'd and correctly refused to record a0rather than silently reporting one — so it was re-run at 10240 MB, where the gate pins tsc itself to its own CI-shaped 6144 MB ceiling.check:dts-closure's green is about this package and not someone else's: it reports15 built package(s) sweptwithout naming them, so the workspace was enumerated — at that point exactly 15 packages had adist/and@objectstack/objectqlwas one of them.Ruled 2026-09-06 by the maintainer, decision batch #54, option A. Implemented by the
domain:engineexecution seat in session01ARYe3yQTQCUFm5qPYNgKaJ(https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ), branchclaude/issue-15964-created-at-unconditional-stamp.Generated by Claude Code