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. 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 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)', () => {