From 796955c6d98fa494191d7301e860d5e1c6e189d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:23:24 +0000 Subject: [PATCH 1/3] docs(objectql): document treatAsHistorical audit-timeline preservation (#3493) The state-machine doc already covered the FSM-skip half of `treatAsHistorical` (#3479); extend the same bullet with the audit-timeline half that shipped in #3497: under the flag the write context also carries `preserveAudit`, so `updated_at`/`updated_by` become client-preferred and a whitelist (audit family + author-declared business `readonly` fields) survives the upsert readonly strip, while platform-managed tenancy/system columns stay stripped. Keeps the one place `treatAsHistorical` is documented accurate and discoverable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5rdfBKjkbcoEif4KUV6xE --- content/docs/protocol/objectql/state-machine.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/protocol/objectql/state-machine.mdx b/content/docs/protocol/objectql/state-machine.mdx index 83321ae06..d329f0eb1 100644 --- a/content/docs/protocol/objectql/state-machine.mdx +++ b/content/docs/protocol/objectql/state-machine.mdx @@ -101,6 +101,7 @@ transitions: { - Only a rule with `severity: 'error'` (the default) blocks the write; `warning`/`info` are logged. - **Seed writes are exempt** (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by `SeedLoaderService` — is a snapshot of established facts, not a record walking its lifecycle, so it bypasses the `state_machine` rule entirely: a seed may be born mid-lifecycle (a `completed` project, a `closed_won` opportunity) and neither `initialStates` (insert) nor `transitions` (update) is enforced. Every *other* validation still runs, so a seed must still satisfy field shape, `format`, `script`, and the rest. `os lint` warns when a seeded value is not a state the machine declares, so a typo is still caught before boot. - **A "historical" data import is exempt too** (#3479). Migrating established facts — a batch of already-`closed` tickets, `closed_won` deals — is the same "snapshot, not a lifecycle event" situation. Set `treatAsHistorical: true` on the import request (default **off**) and the runner puts `skipStateMachine` on the write context, so `initialStates` doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in. +- **`treatAsHistorical` also preserves the original audit timeline** (#3493). Skipping the FSM is only half of migrating established facts; the other half is keeping *when* they happened and *who* did them. Under the same flag the write context also carries `preserveAudit`, which (1) makes `updated_at` / `updated_by` **client-preferred** — a supplied historical last-modified survives instead of being stamped with the import instant, symmetric with how `created_at` / `created_by` already behave on insert — and (2) admits a **whitelist** through the static-`readonly` write strip: the audit/timestamp family plus author-declared business `readonly` fields (`closed_at`, `resolved_by`, …), so an `upsert` refresh no longer silently drops them. Platform-managed `system` columns outside that family (`organization_id` and other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stamps `updated_at`/`updated_by` and strips `readonly` exactly as before, and permissions / RLS / field-level security are unchanged. ### Conditional transitions From c65ab6c07a7f61d3990f5c73329726733f226de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:28:50 +0000 Subject: [PATCH 2/3] chore: add empty changeset for docs-only PR (#3493) The Check Changeset gate requires every PR to add a changeset; the sanctioned declaration for a change that releases nothing (docs-only) is an empty-frontmatter changeset. No version bump. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5rdfBKjkbcoEif4KUV6xE --- .changeset/historical-import-audit-docs.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/historical-import-audit-docs.md diff --git a/.changeset/historical-import-audit-docs.md b/.changeset/historical-import-audit-docs.md new file mode 100644 index 000000000..513dce7fc --- /dev/null +++ b/.changeset/historical-import-audit-docs.md @@ -0,0 +1,4 @@ +--- +--- + +docs(objectql): document that `treatAsHistorical` also preserves the original audit timeline (#3493). Docs-only — releases nothing, so this changeset is intentionally empty. From 90728d7cda8ca7702097c5cc69c2d75b23eeb2b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:34:40 +0000 Subject: [PATCH 3/3] =?UTF-8?q?test(objectql):=20pin=20the=20engine?= =?UTF-8?q?=E2=86=92driver-options=20preserveAudit=20wire=20(#3493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening for #3497. Each layer of the historical-import audit preservation is tested (audit hook + readonly whitelist through the real engine in plugin.integration.test.ts; the driver's updated_at stamp in sql-driver-timestamp-format.test.ts; the runner wiring in import-runner-historical.test.ts) — but nothing asserted that the ENGINE threads `context.preserveAudit` into the DRIVER options, the wire that makes the driver's stamp reachable from a real write. Add that assertion via the shared `buildDriverOptions` observable (same pattern as the tenantId forwarding tests), plus its opt-in negative. Test-only; releases nothing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5rdfBKjkbcoEif4KUV6xE --- packages/objectql/src/engine.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index cf04a7c8a..af4bfb025 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -572,6 +572,23 @@ describe('ObjectQL Engine', () => { await engine.count('task', {}, { context: { tenantId: 't-cnt' } as any }); expect((mockDriver.count as any).mock.calls.at(-1)?.[2]).toMatchObject({ tenantId: 't-cnt' }); }); + + // #3493 — a historical import sets `preserveAudit` on the write context; + // `buildDriverOptions` must thread it into the DRIVER options so the SQL + // driver keeps a supplied `updated_at` instead of force-stamping now. It + // is the SAME shared builder on read and write, so `find` is the cheap + // observable of the wire (as with `tenantId` above); the driver's actual + // honoring of the option lives in @objectstack/driver-sql, and the audit + // hook + readonly whitelist in plugin.integration.test.ts. + it('threads preserveAudit from the context into the driver options (#3493)', async () => { + await engine.find('task', { filters: [] }, { context: { preserveAudit: true } as any }); + expect(lastFindOpts()).toMatchObject({ preserveAudit: true }); + }); + + it('does NOT set preserveAudit when the context omits it (opt-in)', async () => { + await engine.find('task', { filters: [] }, { context: { tenantId: 't' } as any }); + expect(lastFindOpts()?.preserveAudit).toBeUndefined(); + }); }); describe('tenancy.enabled:false objects are platform-global (#3249, ADR-0066)', () => {