diff --git a/.changeset/seed-writes-exempt-state-machine.md b/.changeset/seed-writes-exempt-state-machine.md new file mode 100644 index 0000000000..e3616d00c4 --- /dev/null +++ b/.changeset/seed-writes-exempt-state-machine.md @@ -0,0 +1,27 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': patch +'@objectstack/spec': patch +--- + +Exempt curated seed writes from `state_machine` validation (#3433). + +A seed is a snapshot of established facts — a project already `completed`, an +opportunity already `closed_won` — not a record walking its lifecycle. But once +an object declared `state_machine.initialStates` (#3165), the write path enforced +the FSM entry point on **every** insert, so seed replay silently rejected every +mid-lifecycle row and cascaded its master-detail children. That is the "installed +but no data" failure for the showcase board (1 of 5 projects), and it would hit +every marketplace template (a `closed_won` opportunity, a `closed` case) plus the +rehydrate-heal and per-org replay paths. + +`SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay` +flag; the engine passes `skipStateMachine` to the rule evaluator for those writes, +which skips the `state_machine` rule on both insert (`initialStates`) and update +(transitions). The exemption is scoped to `state_machine` only — a seed must still +satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`, +`conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`, +the fix covers boot inline seed, marketplace install/heal, and per-org replay at once. + +The showcase project seed drops its three-phase FSM-walk workaround (#3415) and +seeds each project directly at its real status again. diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 23279e1a39..8f1955cc65 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -67,6 +67,7 @@ const result = ExecutionContext.parse(data); | **isSystem** | `boolean` | ✅ | | | **skipTriggers** | `boolean` | optional | | | **skipAutomations** | `boolean` | optional | | +| **seedReplay** | `boolean` | optional | | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | diff --git a/examples/app-showcase/src/data/objects/project.object.ts b/examples/app-showcase/src/data/objects/project.object.ts index 0b7beb1569..72291538d3 100644 --- a/examples/app-showcase/src/data/objects/project.object.ts +++ b/examples/app-showcase/src/data/objects/project.object.ts @@ -136,9 +136,7 @@ export const Project = ObjectSchema.create({ active: ['on_hold', 'completed', 'cancelled'], on_hold: ['active', 'cancelled'], // `completed → active` is reopen — a real PM affordance (cancelled can - // already be revived via `planned`). It also keeps the seed's FSM walk - // (#3415) replayable: the walk re-runs on every boot, and a dead-end - // terminal state would reject the hop back through `active`. + // already be revived via `planned`), so no status is a dead end. completed: ['active'], cancelled: ['planned'], }, diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index e49d66ae7c..978ac3127b 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -101,52 +101,28 @@ const contacts = defineSeed(Contact, { }); /** - * Projects seed in THREE phases because `project_status_flow` (#3165) gates - * inserts to `initialStates: ['planned']` and seeds deliberately run - * validation. Writing the target status directly rejected 4 of 5 projects on - * every boot — and their master-detail tasks/memberships with them (#3415). + * Projects span every populated status so the Kanban board, Gantt and + * dashboards show a realistic spread on first boot — not just one column. * - * Phase 1 inserts every project as `planned` — explicitly, because seed - * inserts do NOT apply select defaults (see the Accounts note above) — using - * `mode: 'ignore'` so replays leave already-walked rows completely untouched. - * Phases 2-3 then walk the records along LEGAL transitions, doubling as a - * live demo of the state machine the seed used to violate: - * planned → active (phase 2) - * active → on_hold / completed (phase 3) - * Same-object datasets run in declaration order (stable topological sort). - * On replay, phase 1 skips wholesale (ignore), single-hop rows no-op skip, - * and the two 2-hop rows re-walk `active → terminal` — legal on both edges - * thanks to the `completed → active` reopen transition. Zero rejections. + * `project_status_flow` (#3165) declares `initialStates: ['planned']`, and + * seeds deliberately run validation. But a seed is a curated snapshot of + * ESTABLISHED facts (a project already `active` / `on_hold` / `completed`), + * not a record walking its lifecycle — so the platform exempts seed writes + * from the `state_machine` rule (#3433). That lets each project be seeded + * DIRECTLY at its real status; no FSM-walk workaround (this used to seed all + * five as `planned` then upsert-hop them through legal transitions, #3415). + * `mode: 'upsert'` keeps replay idempotent — an unchanged row no-ops, so + * dev-server restarts and package re-publishes never churn or re-validate. */ const projects = defineSeed(Project, { - mode: 'ignore', - externalId: 'name', - records: [ - { name: 'Website Relaunch', account: 'Northwind', status: 'planned', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, - { name: 'Data Platform', account: 'Contoso', status: 'planned', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` }, - { name: 'Compliance Audit', account: 'Fabrikam', status: 'planned', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` }, - { name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` }, - { name: 'Legacy Sunset', account: 'Northwind', status: 'planned', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` }, - ], -}); - -const projectsActivate = defineSeed(Project, { mode: 'upsert', externalId: 'name', records: [ - { name: 'Website Relaunch', status: 'active' }, - { name: 'Data Platform', status: 'active' }, - { name: 'Compliance Audit', status: 'active' }, - { name: 'Legacy Sunset', status: 'active' }, - ], -}); - -const projectsSettle = defineSeed(Project, { - mode: 'upsert', - externalId: 'name', - records: [ - { name: 'Compliance Audit', status: 'on_hold' }, - { name: 'Legacy Sunset', status: 'completed' }, + { name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, + { name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` }, + { name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` }, + { name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` }, + { name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` }, ], }); @@ -433,4 +409,4 @@ const announcements = defineSeed(Announcement, { ], }); -export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements]; +export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements]; diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 56d478bd48..8d359a0458 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -36,14 +36,20 @@ describe('showcase stack', () => { }); /** - * Static shadow of what SeedLoader + validation do at boot (#3415): for every - * object whose state_machine gates INSERT (`initialStates`), replay the seed - * datasets in declaration order and assert each record enters through a legal - * initial state and only moves along declared transitions. The fixture that - * silently lost 4/5 projects (target status written directly on insert) can - * never come back green. + * Static shadow of the seed contract after #3433: a seed write is a curated + * end-state fact, so the platform EXEMPTS it from the object's `state_machine` + * rule — a project is seeded directly `active` / `on_hold` / `completed` + * without walking the FSM up from `planned` (the three-phase walk workaround + * of #3415 is gone). This guard pins the new contract for every object whose + * state_machine gates INSERT (`initialStates`): + * 1. every seeded value is still a state the FSM DECLARES (a curated fact, + * not a typo — the exemption is not a license to write garbage); and + * 2. the fixture actually EXERCISES the exemption by seeding ≥1 non-initial + * state, so a regression back to "all rows enter as the initial state" + * (a re-introduced walk, or a fixture that collapses the board to one + * column) fails here. The #3433 failure was 1/5 projects surviving. */ -describe('seed data vs state machines (#3415)', () => { +describe('seed data vs state machines (#3433)', () => { const gated = (stack.objects ?? []).flatMap((o: any) => (o.validations ?? []) .filter( @@ -56,40 +62,46 @@ describe('seed data vs state machines (#3415)', () => { .map((v: any) => ({ object: o, rule: v })), ); - it('covers the project status flow (the #3415 gate)', () => { + it('covers the project status flow (the #3433 gate)', () => { expect(gated.map((g: any) => `${g.object.name}.${g.rule.field}`)).toContain('showcase_project.status'); }); for (const { object, rule } of gated) { - it(`${object.name}: seeded '${rule.field}' respects initialStates and transitions — including on replay`, () => { + it(`${object.name}: seeded '${rule.field}' is FSM-exempt but stays within declared states (#3433)`, () => { const datasets = ShowcaseSeedData.filter((d: any) => d.object === object.name); expect(datasets.length).toBeGreaterThan(0); - const current = new Map(); - // Round 1 = fresh boot; round 2 = replay against the walked state. - // Replay must also be violation-free (#3415 follow-up): `ignore` - // datasets skip existing rows wholesale, and re-walked hops must be - // legal transitions (which is what the reopen edge guarantees). - for (const round of [1, 2]) { - for (const ds of datasets as any[]) { - for (const rec of ds.records as any[]) { - const key = String(rec[ds.externalId ?? 'name']); - const next = rec[rule.field]; - if (!current.has(key)) { - // First appearance = INSERT. Seed inserts do NOT apply select - // defaults, so a gated field must be explicit AND legal. - expect(next, `'${key}' (round ${round}) must seed '${rule.field}' explicitly`).toBeDefined(); - expect(rule.initialStates, `'${key}' enters as '${next}'`).toContain(next); - current.set(key, next); - } else { - if (ds.mode === 'ignore') continue; // existing rows untouched - if (next === undefined || next === current.get(key)) continue; // no-op replay skips - const from = current.get(key)!; - expect(rule.transitions?.[from] ?? [], `'${key}' (round ${round}) ${from} → ${next}`).toContain(next); - current.set(key, next); - } - } + + // Every state the FSM knows about — the legal value universe, derived + // from the rule itself (no dependency on the field's option shape). + const fsmStates = new Set( + [ + ...rule.initialStates, + ...Object.keys(rule.transitions ?? {}), + ...Object.values(rule.transitions ?? {}).flat(), + ].map(String), + ); + + const seeded = new Set(); + for (const ds of datasets as any[]) { + for (const rec of ds.records as any[]) { + const v = rec[rule.field]; + if (v === undefined || v === null) continue; + const key = String(rec[ds.externalId ?? 'name']); + // #3433: a seed value need NOT be an initialState (the FSM entry + // guard is exempt), but it must be a state the machine declares. + expect(fsmStates, `'${key}' seeds '${rule.field}=${String(v)}'`).toContain(String(v)); + seeded.add(String(v)); } } + + // The exemption must actually be used: seed at least one state the FSM + // entry point would reject on INSERT. Guards against a silent regression + // to a planned-only fixture (or a re-introduced FSM walk). + const nonInitial = [...seeded].filter((v) => !rule.initialStates.includes(v)); + expect( + nonInitial.length, + `${object.name} seeds only initial states (${[...seeded].join(', ')}) — #3433 exemption unused`, + ).toBeGreaterThan(0); }); } }); diff --git a/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts b/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts new file mode 100644 index 0000000000..ab6d161c19 --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; + +/** + * #3433 — a curated seed is a snapshot of ESTABLISHED facts (a project already + * `completed`, an opportunity `closed_won`), not a record walking its lifecycle. + * When an object declares `state_machine.initialStates` (#3165), the write path + * enforces that INSERTS are born in an initial state — which silently rejects + * every mid-lifecycle seed row and cascades its master-detail children ("installed + * but no data"). So SeedLoaderService marks its writes `seedReplay`, and the engine + * skips the state_machine rule for them. + * + * The engine that actually enforces this lives in @objectstack/objectql, which + * DEPENDS ON this package — importing it back would cycle. So this mock engine + * reproduces the exact insert-time guard (reject a state ∉ initialStates UNLESS the + * write carries `context.seedReplay`) to regression-test the loader's end of the + * contract in isolation. Revert the `seedReplay` flag in `SEED_OPTIONS` and both + * cases below go red — 4 of 5 rows rejected, the flag absent from the writes. + */ + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +const PROJECT = { + name: 'showcase_project', + fields: { + name: { type: 'text' }, + status: { type: 'select' }, + }, + validations: [ + { + type: 'state_machine', + name: 'project_status_flow', + field: 'status', + initialStates: ['planned'], + transitions: { + planned: ['active', 'cancelled'], + active: ['on_hold', 'completed', 'cancelled'], + }, + message: 'Invalid project status transition.', + }, + ], +}; + +/** Reproduces the objectql insert-time initialStates guard, honoring the #3433 exemption. */ +function enforceInitialStates(data: Record, opts: any): void { + if (opts?.context?.seedReplay === true) return; // #3433 exemption + const sm = PROJECT.validations[0]; + const value = data[sm.field]; + if (value == null || value === '') return; + if (!sm.initialStates.includes(String(value))) { + const err: any = new Error( + `invalid_initial_state: ${sm.field} '${String(value)}' not in [${sm.initialStates.join(', ')}]`, + ); + err.code = 'VALIDATION_FAILED'; + throw err; + } +} + +function createEnforcingEngine(): { engine: IDataEngine; store: Record } { + const store: Record = {}; + let idCounter = 0; + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let rows = store[objectName] || []; + if (query?.where) { + rows = rows.filter((r) => Object.entries(query.where).every(([k, v]) => r[k] === v)); + } + if (typeof query?.limit === 'number') rows = rows.slice(0, query.limit); + return rows; + }), + findOne: vi.fn(async () => null), + // Per-row partial-success path the seed loader prefers (framework#3172): + // one verdict per row, so a rejected row is culled, not thrown as a batch. + insertMany: vi.fn(async (objectName: string, rows: any[], opts: any) => + rows.map((r) => { + try { + enforceInitialStates(r, opts); + const record = { id: `gen-${++idCounter}`, ...r }; + (store[objectName] ||= []).push(record); + return { ok: true, record }; + } catch (error) { + return { ok: false, error }; + } + }), + ), + insert: vi.fn(async (objectName: string, data: any, opts: any) => { + const rows = Array.isArray(data) ? data : [data]; + const written = rows.map((r) => { + enforceInitialStates(r, opts); // throws on violation (whole-array semantics) + const record = { id: `gen-${++idCounter}`, ...r }; + (store[objectName] ||= []).push(record); + return record; + }); + return Array.isArray(data) ? written : written[0]; + }), + update: vi.fn(async (objectName: string, data: any) => { + const rows = store[objectName] || []; + const idx = rows.findIndex((r) => r.id === data.id); + if (idx >= 0) { + rows[idx] = { ...rows[idx], ...data }; + return rows[idx]; + } + return data; + }), + delete: vi.fn(async () => ({ deleted: 1 })), + count: vi.fn(async (o: string) => (store[o] || []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + return { engine, store }; +} + +function createMetadata(): IMetadataService { + return { + getObject: vi.fn(async (name: string) => (name === PROJECT.name ? PROJECT : undefined)), + listObjects: vi.fn(async () => [PROJECT]), + register: vi.fn(async () => {}), + get: vi.fn(async (_t: string, name: string) => (name === PROJECT.name ? PROJECT : undefined)), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on). +const CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, +} as any; + +// A seed that deliberately spans the lifecycle: 1 born-initial + 4 mid-lifecycle, +// exactly like the showcase project board (one card per Kanban column). +const SEED = [ + { + object: 'showcase_project', + externalId: 'name', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [ + { name: 'Mobile App', status: 'planned' }, + { name: 'Website Relaunch', status: 'active' }, + { name: 'Data Platform', status: 'active' }, + { name: 'Compliance Audit', status: 'on_hold' }, + { name: 'Legacy Sunset', status: 'completed' }, + ], + }, +] as any[]; + +describe('seed loader — state_machine initialStates exemption (#3433)', () => { + it('inserts every mid-lifecycle row on a fresh DB (no initialStates rejection)', async () => { + const { engine, store } = createEnforcingEngine(); + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ + seeds: SEED, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(result.summary.totalErrored).toBe(0); + expect(result.summary.totalInserted).toBe(5); + expect(store.showcase_project).toHaveLength(5); + // The exact spread the showcase board needs — proof no state was dropped. + expect(store.showcase_project.map((r) => r.status).sort()).toEqual([ + 'active', + 'active', + 'completed', + 'on_hold', + 'planned', + ]); + }); + + it('threads seedReplay into every project write (the flag the engine keys off)', async () => { + const { engine } = createEnforcingEngine(); + await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ + seeds: SEED, + config: CONFIG, + }); + + // Whichever write path the loader took (insertMany batch or a per-row + // fallback), its options must carry the exemption flag — that is what the + // engine reads to skip the state_machine rule. + const writeCalls = [ + ...(engine.insertMany as any).mock.calls, + ...(engine.insert as any).mock.calls, + ].filter(([obj]) => obj === 'showcase_project'); + expect(writeCalls.length).toBeGreaterThan(0); + for (const call of writeCalls) { + expect(call[2]?.context?.seedReplay).toBe(true); + } + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index ab33b126ad..de9e21c98f 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -870,8 +870,19 @@ export class SeedLoaderService implements ISeedLoaderService { * approvals) for it is semantically wrong and dangerous — a self-triggering * flow can loop and wedge the whole first-boot (2026-07-06 incident). * Lifecycle HOOKS (derived/default fields, validation) still run. + * + * `seedReplay` (#3433) tells the engine this is curated seed data so the + * object's `state_machine` validation rule is skipped — both the + * `initialStates` entry-point check on insert and the transition check on + * update. A seed is a snapshot of established facts (a `completed` project, a + * `closed_won` opportunity), not a record walking its lifecycle, so the FSM + * entry/transition guards do not apply. Without this a declared + * `initialStates` silently rejects every mid-lifecycle seed row and cascades + * its master-detail children — the "installed but no data" failure for + * showcase and every marketplace template. All OTHER validation (field + * shape, `format`, `cross_field`, `script`, `json_schema`) still runs. */ - private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true } } as const; + private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } } as const; /** * Run an engine write; if it fails ONLY because a post-write roll-up summary diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 62e4637f8b..d8c3fec425 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -858,6 +858,71 @@ describe('ObjectQL Engine', () => { }); }); + describe('seed writes bypass state_machine validation (#3433)', () => { + // A curated seed row can be born mid-lifecycle (a project already + // `completed`, an opportunity `closed_won`). The engine skips the + // `state_machine` rule for writes whose context carries `seedReplay` + // (set by SeedLoaderService) — otherwise a declared `initialStates` + // silently rejects every such row on INSERT and cascades its children. + const approvalObject = { + name: 'seed_approval', + fields: { + status: { + type: 'select', + options: [ + { value: 'draft', label: 'Draft' }, + { value: 'pending', label: 'Pending' }, + { value: 'approved', label: 'Approved' }, + ], + }, + }, + validations: [ + { + type: 'state_machine', + name: 'approval_flow', + field: 'status', + message: 'A request must start as draft.', + initialStates: ['draft'], + transitions: { draft: ['pending'], pending: ['approved'] }, + }, + ], + }; + + beforeEach(async () => { + engine.registerDriver(mockDriver, true); + await engine.init(); + }); + + it('rejects a mid-lifecycle INSERT under a normal context (control)', async () => { + vi.mocked(SchemaRegistry.getObject).mockReturnValue(approvalObject as any); + await expect( + engine.insert('seed_approval', { status: 'approved' }), + ).rejects.toThrow(/must start as draft/i); + expect(mockDriver.create).not.toHaveBeenCalled(); + }); + + it('admits the same INSERT when the context carries seedReplay', async () => { + vi.mocked(SchemaRegistry.getObject).mockReturnValue(approvalObject as any); + await engine.insert('seed_approval', { status: 'approved' }, { context: { seedReplay: true } as any }); + expect(mockDriver.create).toHaveBeenCalledTimes(1); + }); + + it('still enforces non-state_machine rules under seedReplay (scoped exemption)', async () => { + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ + ...approvalObject, + fields: { ...approvalObject.fields, email: { type: 'text' } }, + validations: [ + approvalObject.validations[0], + { type: 'format', name: 'email_format', message: 'email must be a valid email', field: 'email', format: 'email' }, + ], + } as any); + await expect( + engine.insert('seed_approval', { status: 'approved', email: 'not-an-email' }, { context: { seedReplay: true } as any }), + ).rejects.toThrow(/valid email/); + expect(mockDriver.create).not.toHaveBeenCalled(); + }); + }); + describe('Bulk update validation enforcement (#3106)', () => { beforeEach(async () => { engine.registerDriver(mockDriver, true); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e630c374f3..b5df12c50e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2450,7 +2450,7 @@ export class ObjectQL implements IDataEngine { try { normalizeMultiValueFields(schemaForValidation, rows[i]); validateRecord(schemaForValidation, rows[i], 'insert'); - evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) }); + evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: opCtx.context?.seedReplay === true }); } catch (e) { if (!partialMode) throw e; rowErrors[i] = e; @@ -2740,7 +2740,7 @@ export class ObjectQL implements IDataEngine { hookContext.input.data = stripReadonlyFields(updateSchema as any, preRo, suppliedKeys, this.logger) as any; reportDroppedFields(preRo, hookContext.input.data as Record, 'readonly'); } - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: opCtx.context?.seedReplay === true }); result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } else if (options?.multi && driver.updateMany) { await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options); @@ -2808,7 +2808,7 @@ export class ObjectQL implements IDataEngine { if (rulesNeedRows) { for (const row of priorRows ?? []) { try { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: opCtx.context?.seedReplay === true }); } catch (err) { if (err instanceof ValidationError && row?.id != null) { throw new ValidationError(err.fields.map((f) => ({ ...f, message: `${f.message} (record ${String(row.id)})` }))); @@ -2817,7 +2817,7 @@ export class ObjectQL implements IDataEngine { } } } else { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: opCtx.context?.seedReplay === true }); } result = await driver.updateMany(object, ast, hookContext.input.data as Record, hookContext.input.options as any); } else { diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 7ad7e1037d..d80f54e893 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -303,6 +303,86 @@ describe('state_machine initialStates enforcement on INSERT (#3165)', () => { }); }); +// #3433 — seed writes are curated end-state facts, not lifecycle events, so the +// engine passes `skipStateMachine` for them: the `state_machine` rule is skipped +// on BOTH insert (initialStates) and update (transitions), while every OTHER +// rule still runs. Each case pairs the exempted call with an un-flagged control +// so the exemption can't silently become always-on. +describe('skipStateMachine exemption for seed writes (#3433)', () => { + const flowSchema = { + validations: [ + { + type: 'state_machine' as const, + name: 'approval_flow', + field: 'approval_status', + message: 'A request must start as draft.', + initialStates: ['draft'], + transitions: { + draft: ['pending'], + pending: ['approved', 'rejected'], + }, + }, + ], + }; + + it('bypasses the initialStates entry check on INSERT (born mid-flow)', () => { + // Control: enforced without the flag. + expect(() => + evaluateValidationRules(flowSchema, { approval_status: 'approved' }, 'insert'), + ).toThrow(ValidationError); + expect(() => + evaluateValidationRules(flowSchema, { approval_status: 'approved' }, 'insert', { + skipStateMachine: true, + }), + ).not.toThrow(); + }); + + it('bypasses the transition check on UPDATE (illegal hop draft → approved)', () => { + // Control: draft → approved is not a declared transition. + expect(() => + evaluateValidationRules(flowSchema, { approval_status: 'approved' }, 'update', { + previous: { approval_status: 'draft' }, + }), + ).toThrow(ValidationError); + expect(() => + evaluateValidationRules(flowSchema, { approval_status: 'approved' }, 'update', { + previous: { approval_status: 'draft' }, + skipStateMachine: true, + }), + ).not.toThrow(); + }); + + it('skips ONLY state_machine — other rules (script) still fire under the flag', () => { + const guardedSchema = { + validations: [ + flowSchema.validations[0], + { + type: 'script' as const, + name: 'amount_non_negative', + condition: { dialect: 'cel' as const, source: 'record.amount < 0' }, + message: 'amount must be non-negative', + }, + ], + }; + // FSM skipped + script satisfied → clean. + expect(() => + evaluateValidationRules(guardedSchema, { approval_status: 'approved', amount: 5 }, 'insert', { + skipStateMachine: true, + }), + ).not.toThrow(); + // FSM skipped but script violated → still rejected (exemption is scoped). + try { + evaluateValidationRules(guardedSchema, { approval_status: 'approved', amount: -1 }, 'insert', { + skipStateMachine: true, + }); + throw new Error('expected throw'); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + expect((e as ValidationError).fields[0].message).toBe('amount must be non-negative'); + } + }); +}); + describe('execution control', () => { it('skips inactive rules', () => { const schema = { diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 608d951fd2..1e8d5e54d3 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -163,6 +163,17 @@ export interface EvaluateRulesOptions { * and fail-open (see {@link evaluateOptionVisibility}). */ currentUser?: { id?: string; roles?: string[]; organizationId?: string | null; [k: string]: unknown } | null; + /** + * When true, `state_machine` rules are skipped entirely — both the + * `initialStates` entry-point check on insert (#3165) and the transition + * check on update. Set by the engine for CURATED SEED writes + * (`ExecutionContext.seedReplay`, #3433): a seed is a snapshot of established + * facts, not a record flowing through its lifecycle, so FSM entry/transition + * guards do not apply to it. ONLY `state_machine` is skipped; every other + * rule type still runs (a seed must still satisfy `format`, `cross_field`, + * `script`, `json_schema`, `conditional`). + */ + skipStateMachine?: boolean; } /** @@ -543,6 +554,11 @@ export function evaluateValidationRules( const ordered = (hasRules ? rules! : []) .filter((r): r is BaseRule => r != null && typeof r === 'object') .filter((r) => r.active !== false) + // Seed writes (#3433) skip `state_machine` entirely: curated seed data is a + // snapshot of established facts, not a record flowing through its lifecycle, + // so neither the `initialStates` entry-point (insert) nor the transition + // (update) guard applies. Every other rule type still runs. + .filter((r) => !(opts.skipStateMachine && r.type === 'state_machine')) .filter((r) => { const events = r.events ?? ['insert', 'update']; return events.includes(mode); diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 4f57f336a0..0888fcddd3 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -194,6 +194,30 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ skipAutomations: z.boolean().optional(), + /** + * True when this write is CURATED SEED data being (re)loaded by the seed + * loader — a package's bootstrap/demo dataset, not a user-initiated event. + * Set only by `SeedLoaderService` (server-constructed, never client-supplied, + * exactly like {@link isSystem}). + * + * A seed is a Salesforce-sandbox-style snapshot of established facts: an + * opportunity that is already `closed_won`, a case already `closed`, a + * project already `completed`. It is NOT the record flowing through its + * lifecycle. So the object's `state_machine` validation rule — the FSM + * ENTRY-POINT check on insert (`initialStates`, #3165) and the TRANSITION + * check on update — does not apply and is skipped for these writes. Without + * this exemption a declared `initialStates` silently rejects every + * mid-lifecycle seed row (and cascades its master-detail children), which is + * why marketplace templates and heal/replay "installed but no data" (#3433). + * + * SCOPE: skips ONLY the `state_machine` rule. Every other validation + * (field shape, `format`, `json_schema`, `cross_field`, `script`, + * `conditional`) still runs — a seed with a malformed email or an + * over-budget spend is a genuine bug worth catching. Automation is a + * SEPARATE concern handled by {@link skipTriggers} (also set for seeds). + */ + seedReplay: z.boolean().optional(), + /** * OAuth 2.1 scopes granted to the access token that authenticated this * request, when the principal was resolved from an OAuth bearer token