Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/historical-import-audit-docs.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/protocol/objectql/state-machine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down