From 3f94261d6459ef803fd3a2821f3ad07630fbd351 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:28:18 +0800 Subject: [PATCH] feat(lint): warn on seed values outside the declared state machine (#3433 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the #3433 seed / state-machine exemption: - lint: `validateSeedStateMachine` — an author-time `os validate` / `os lint` warning when a seed record's `state_machine`-governed field carries a value the machine does not declare (initialStates ∪ transition keys ∪ targets). #3433 exempts seeds from the FSM at write time, so this re-adds the "is this even a known state?" safety net a typo would otherwise slip past. Advisory, symmetric with the #3434 replay-safety rule. Rule id `seed-value-outside-state-machine`. - test(cloud-connection): a marketplace-install integration test that drives the real install handler → runInlineSeed → SeedLoaderService against an engine stub reproducing the #3165 initialStates guard, proving a template whose seed spans the whole pipeline (prospecting → closed_won) lands every row on install. Red without the #3433 exemption (verified). - docs: note the seed exemption in the state-machine, validation, and seed-data guides — seeds are established facts, not lifecycle events; every other validation still runs; `os lint` catches an unknown-state typo before boot. The remaining #3433 follow-up — #3434 (mode:'insert' seeds duplicate on replay) — was already closed by #3442 (composite externalId) + #3460 (replay-safety lint), so it needs no change here. Co-Authored-By: Claude Fable 5 --- .changeset/seed-state-machine-lint.md | 21 ++ content/docs/data-modeling/seed-data.mdx | 19 ++ content/docs/data-modeling/validation.mdx | 2 + .../docs/protocol/objectql/state-machine.mdx | 1 + packages/cli/src/commands/lint.ts | 17 +- ...install-local-state-machine-exempt.test.ts | 245 ++++++++++++++++++ packages/lint/src/index.ts | 6 + .../src/validate-seed-state-machine.test.ts | 116 +++++++++ .../lint/src/validate-seed-state-machine.ts | 157 +++++++++++ 9 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 .changeset/seed-state-machine-lint.md create mode 100644 packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts create mode 100644 packages/lint/src/validate-seed-state-machine.test.ts create mode 100644 packages/lint/src/validate-seed-state-machine.ts diff --git a/.changeset/seed-state-machine-lint.md b/.changeset/seed-state-machine-lint.md new file mode 100644 index 0000000000..bb54a512a8 --- /dev/null +++ b/.changeset/seed-state-machine-lint.md @@ -0,0 +1,21 @@ +--- +"@objectstack/lint": patch +"@objectstack/cli": patch +--- + +feat(lint): warn on seed values outside an object's declared state machine (#3433 follow-up) + +#3433 exempts seed writes from the `state_machine` validation rule, so a seeded +status the FSM does not declare is no longer rejected at write time. A field-level +`select` still catches a value outside its `options`, but a `state_machine` on a +free-text field — or a value that is a valid option yet not a declared FSM state — +now sails through silently: the exemption is a deliberate but blind back door. + +`validateSeedStateMachine` (a pure `(stack) => Finding[]` rule, run from +`os validate` / `os lint`, symmetric with the replay-safety rule from #3434) +re-adds that safety net at author time. It flags any seed record whose +`state_machine`-governed field carries a value outside the machine's declared +states — the union of `initialStates`, the transition-map keys, and the transition +targets. Advisory (`warning`): the exemption itself is legitimate, so the fix-it +points at either adding the state to the machine or correcting the typo, not a hard +build failure. New rule id: `seed-value-outside-state-machine`. diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 9f0a8a91a7..8c05bac394 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -126,6 +126,25 @@ It is almost never the right mode: reach for `upsert` (or `ignore`) with a stabl --- +## Seeds and State Machines + +A seed is a curated snapshot of **established facts** — a project already +`completed`, an opportunity already `closed_won` — not a record walking its +lifecycle. So a seed write is **exempt from the object's `state_machine` rule** +([#3433](https://github.com/objectstack-ai/objectstack/issues/3433)): it may be +born in any state, and neither `initialStates` (the insert entry guard) nor +`transitions` (the update guard) is enforced. Without this exemption a fixture +that seeds a mid-lifecycle status would be silently rejected on the first boot, +taking its lookup children down with it — the "installed but no data" trap. + +The exemption is scoped to the state machine **only**. Every other validation +still runs, so a seeded value must still be a valid field option and pass +`format`, `cross_field`, and the rest. And `os validate` warns when a seeded +value is not a state the machine declares (`seed-value-outside-state-machine`), +so a typo like `'complete'` for `'completed'` still surfaces before boot. + +--- + ## Environment Scoping The `env` array controls which deployment environments receive the records. The diff --git a/content/docs/data-modeling/validation.mdx b/content/docs/data-modeling/validation.mdx index fdf315b3a6..0ba0d6cd9e 100644 --- a/content/docs/data-modeling/validation.mdx +++ b/content/docs/data-modeling/validation.mdx @@ -182,6 +182,8 @@ Enforce allowed state transitions: `transitions` governs updates; `initialStates` governs the create. A rule may declare either or both. +**Seed data is exempt from the state machine** (#3433). Curated fixtures loaded by `SeedLoaderService` may be born in any declared state — a project seeded `completed`, an opportunity `closed_won` — without `initialStates` or `transitions` being enforced. Seeds are established facts, not lifecycle events. Every other validation still applies, and `os lint` warns if a seeded value is not a state the machine declares, so typos surface before boot. + ### Cross-Field Validation Validate relationships between multiple fields: diff --git a/content/docs/protocol/objectql/state-machine.mdx b/content/docs/protocol/objectql/state-machine.mdx index 7b51276e53..dc1b46d7db 100644 --- a/content/docs/protocol/objectql/state-machine.mdx +++ b/content/docs/protocol/objectql/state-machine.mdx @@ -99,6 +99,7 @@ transitions: { - **On update**, if the state field changed and the new value is **not** in `transitions[oldValue]`, the write is rejected. - The check is **lenient where it cannot reason**: if the prior state is not described by the table (e.g. legacy or externally-written data), it does not block. - 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. ### Conditional transitions diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 5dc7835c20..669ff842a4 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -9,7 +9,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers, validateSeedReplaySafety } from '@objectstack/lint'; +import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -455,6 +455,21 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Seed value vs state machine (framework#3433 follow-up) ── + // #3433 exempts seed writes from the `state_machine` rule, so a seeded status + // the FSM does not declare is no longer rejected at write time. Re-add that + // safety net at author time: a value outside the machine's declared states is + // almost certainly a typo. Advisory — the exemption itself is legitimate. + for (const t of validateSeedStateMachine(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts b/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts new file mode 100644 index 0000000000..dbf1fe55a4 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Marketplace install of a template whose objects declare a `state_machine` + * with `initialStates` MUST NOT drop the mid-lifecycle seed rows (framework#3433). + * + * A marketplace template is a curated snapshot: its seed almost always contains + * rows past the FSM entry point — a `closed_won` opportunity, a `closed` case, a + * `completed` project. #3165 made `initialStates` reject any INSERT outside the + * entry set, which would silently drop every such row on install / rehydrate-heal + * / per-org replay ("installed but no data"). #3433 fixed it at the platform: + * `SeedLoaderService.SEED_OPTIONS` carries `seedReplay`, and the engine skips the + * `state_machine` rule for those writes. + * + * This test drives the REAL marketplace install path (the plugin's HTTP handler + * → dynamic import of the real runtime SeedLoaderService → runInlineSeed) against + * an engine stub that FAITHFULLY reproduces the #3165 guard: an insert whose + * state ∉ `initialStates` is rejected UNLESS the write carries + * `context.seedReplay`. So the assertion below is exactly the #3433 contract on + * the marketplace seam — remove the flag from `SEED_OPTIONS` and this goes red + * (3 of 4 deals dropped, `seeded.errors` > 0). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeCtx(rawApp: any, services: Record) { + const hooks = new Map(); + return { + ctx: { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }, + fire: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + header: () => undefined, + }, + json, + }; +} + +/** + * Engine stub that reproduces the #3165 insert-time `initialStates` guard the + * REAL ObjectQL engine applies — and honors the #3433 `seedReplay` exemption. + * Everything else mirrors the faithful stub in the seed-lookup test. + */ +function makeEngine() { + const store: Record = {}; + const registry: Record = {}; + let idCounter = 0; + + const enforceInitialState = (objectName: string, rec: any, opts: any) => { + if (opts?.context?.seedReplay === true) return; // #3433 exemption + const schema = registry[objectName]; + const sm = (schema?.validations ?? []).find( + (v: any) => v?.type === 'state_machine' && Array.isArray(v.initialStates), + ); + if (!sm) return; + const v = rec?.[sm.field]; + if (v == null || v === '') return; + if (!sm.initialStates.includes(String(v))) { + const e: any = new Error(`invalid_initial_state: ${sm.field}='${String(v)}'`); + e.code = 'VALIDATION_FAILED'; + throw e; + } + }; + + const engine: any = { + find: async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => r[k] === v), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records; + }, + insert: async (objectName: string, data: any, opts?: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + // Whole-array insert: a bad row throws the batch (the loader's + // bulkWrite then degrades to per-row writeOne, exactly like the + // real engine path). + const records = data.map((d) => { + enforceInitialState(objectName, d, opts); + return { id: `row-${++idCounter}`, ...d }; + }); + store[objectName].push(...records); + return records; + } + enforceInitialState(objectName, data, opts); + const record = { id: `row-${++idCounter}`, ...data }; + store[objectName].push(record); + return record; + }, + update: async (objectName: string, data: any) => { + const records = store[objectName] || []; + const idx = records.findIndex((r) => r.id === data.id); + if (idx >= 0) { + records[idx] = { ...records[idx], ...data }; + return records[idx]; + } + return data; + }, + delete: async () => ({ deleted: 1 }), + count: async (objectName: string) => (store[objectName] || []).length, + aggregate: async () => [], + getSchema: (name: string) => registry[name], + syncSchemas: async () => undefined, + registerApp: (manifest: any) => { + for (const obj of manifest?.objects ?? []) { + if (obj?.name) registry[obj.name] = obj; + } + }, + }; + return { engine, store, registry }; +} + +/** A template package whose `deal` object gates INSERT to `prospecting`, with a + * seed that deliberately spans the whole pipeline (the marketplace reality). */ +const PIPELINE_MANIFEST = { + id: 'app.test.pipeline', + name: 'Pipeline Test', + version: '1.0.0', + objects: [ + { + name: 'deal', + label: 'Deal', + fields: { + name: { type: 'text', label: 'Name', required: true }, + stage: { + type: 'select', + label: 'Stage', + options: [ + { value: 'prospecting' }, + { value: 'negotiation' }, + { value: 'closed_won' }, + { value: 'closed_lost' }, + ], + }, + }, + validations: [ + { + type: 'state_machine', + name: 'deal_stage_flow', + field: 'stage', + events: ['insert', 'update'], + initialStates: ['prospecting'], + transitions: { + prospecting: ['negotiation', 'closed_lost'], + negotiation: ['closed_won', 'closed_lost'], + }, + message: 'Invalid deal stage.', + }, + ], + }, + ], + data: [ + { + object: 'deal', + externalId: 'name', + mode: 'upsert', + records: [ + { name: 'Acme Renewal', stage: 'prospecting' }, // the entry state + { name: 'Globex Expansion', stage: 'negotiation' }, // mid-lifecycle + { name: 'Initech Migration', stage: 'closed_won' }, // terminal — the killer + { name: 'Umbrella Deal', stage: 'closed_lost' }, // terminal + ], + }, + ], +}; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-fsm-exempt-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +describe('marketplace install — state_machine initialStates exemption (#3433)', () => { + it('lands every mid-lifecycle seed row (no initialStates rejection on the marketplace seam)', { timeout: 30_000 }, async () => { + const { engine, store, registry } = makeEngine(); + const rawApp = makeRawApp(); + const { ctx, fire } = makeCtx(rawApp, { + manifest: { register: (m: any) => engine.registerApp(m) }, + auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, + objectql: engine, + metadata: { getObject: vi.fn(async () => undefined), list: vi.fn(async () => []) }, + }); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await fire(); + + const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!( + makeC({ manifest: PIPELINE_MANIFEST }), + ); + + expect(res.payload?.success).toBe(true); + // The object registered (engine registry) and the guard is armed. + expect(registry.deal?.validations?.[0]?.initialStates).toEqual(['prospecting']); + + // The inline seed ran and landed EVERY row — including the three that + // start past the FSM entry point. Without the #3433 exemption the stub + // would reject negotiation/closed_won/closed_lost and this is 1, errors > 0. + expect(res.payload?.data?.seeded?.mode).toBe('inline'); + expect(res.payload?.data?.seeded?.errors).toBe(0); + expect(store.deal).toHaveLength(4); + expect(store.deal.map((r) => r.stage).sort()).toEqual([ + 'closed_lost', + 'closed_won', + 'negotiation', + 'prospecting', + ]); + }); +}); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 33225b872e..592906c5c4 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -126,6 +126,12 @@ export { } from './validate-seed-replay-safety.js'; export type { SeedReplaySafetyFinding, SeedReplaySafetySeverity } from './validate-seed-replay-safety.js'; +export { + validateSeedStateMachine, + SEED_VALUE_OUTSIDE_STATE_MACHINE, +} from './validate-seed-state-machine.js'; +export type { SeedStateMachineFinding, SeedStateMachineSeverity } from './validate-seed-state-machine.js'; + export { validateSecurityPosture, SECURITY_OWD_UNSET, diff --git a/packages/lint/src/validate-seed-state-machine.test.ts b/packages/lint/src/validate-seed-state-machine.test.ts new file mode 100644 index 0000000000..6af60a804c --- /dev/null +++ b/packages/lint/src/validate-seed-state-machine.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateSeedStateMachine, + SEED_VALUE_OUTSIDE_STATE_MACHINE, +} from './validate-seed-state-machine.js'; + +// Mirrors the showcase project: an FSM with initialStates + transitions, and a +// second (health) machine with transitions only. +const objects = [ + { + name: 'proj', + fields: { status: { type: 'select' }, health: { type: 'select' } }, + validations: [ + { + type: 'state_machine', + field: 'status', + initialStates: ['planned'], + transitions: { + planned: ['active', 'cancelled'], + active: ['on_hold', 'completed', 'cancelled'], + completed: ['active'], + }, + }, + { + type: 'state_machine', + field: 'health', + transitions: { green: ['yellow'], yellow: ['green', 'red'], red: ['yellow'] }, + }, + ], + }, + { name: 'plain', fields: { title: { type: 'text' } } }, // no state machine +]; + +const seed = (records: any[], object = 'proj', externalId: any = 'name') => ({ + objects, + data: [{ object, externalId, records }], +}); + +describe('validateSeedStateMachine (#3433 follow-up)', () => { + it('is clean when every seeded value is a declared FSM state (incl. mid-lifecycle)', () => { + // active / on_hold / completed are NOT initial states — the #3433 exemption + // lets them be seeded — but they ARE declared, so the guard stays quiet. + const findings = validateSeedStateMachine( + seed([ + { name: 'A', status: 'planned', health: 'green' }, + { name: 'B', status: 'active', health: 'yellow' }, + { name: 'C', status: 'on_hold', health: 'red' }, + { name: 'D', status: 'completed', health: 'green' }, + { name: 'E', status: 'cancelled', health: 'yellow' }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('flags a value the state machine does not declare (typo)', () => { + const findings = validateSeedStateMachine(seed([{ name: 'Legacy Sunset', status: 'complete' }])); + expect(findings).toHaveLength(1); + const f = findings[0]; + expect(f.rule).toBe(SEED_VALUE_OUTSIDE_STATE_MACHINE); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('data[0].records[0].status'); + expect(f.where).toContain('proj'); + expect(f.where).toContain('Legacy Sunset'); + expect(f.message).toContain('complete'); + }); + + it('checks every state_machine on the object (health too)', () => { + const findings = validateSeedStateMachine(seed([{ name: 'A', status: 'active', health: 'blue' }])); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('data[0].records[0].health'); + expect(findings[0].message).toContain('blue'); + }); + + it('labels a composite-externalId record by its key parts', () => { + const findings = validateSeedStateMachine( + seed([{ team: 'X', project: 'Y', status: 'bogus' }], 'proj', ['team', 'project']), + ); + expect(findings).toHaveLength(1); + expect(findings[0].where).toContain('X · Y'); + }); + + it('ignores objects with no state machine', () => { + expect(validateSeedStateMachine(seed([{ title: 'whatever' }], 'plain'))).toEqual([]); + }); + + it('skips non-string values (unresolved cel Expression, number) — not statically checkable', () => { + const findings = validateSeedStateMachine( + seed([ + { name: 'A', status: { dialect: 'cel', source: 'someExpr()' } }, // Expression envelope + { name: 'B', status: 42 as any }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('is safe on a stack with no objects or no data', () => { + expect(validateSeedStateMachine({})).toEqual([]); + expect(validateSeedStateMachine({ objects, data: [] })).toEqual([]); + expect(validateSeedStateMachine({ objects: [], data: [{ object: 'proj', records: [{ status: 'x' }] }] })).toEqual( + [], + ); + }); + + it('ignores a state_machine that declares no states (nothing to check against)', () => { + const emptyFsm = [ + { name: 'proj', validations: [{ type: 'state_machine', field: 'status' }] }, + ]; + const findings = validateSeedStateMachine({ + objects: emptyFsm, + data: [{ object: 'proj', records: [{ status: 'anything' }] }], + }); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-seed-state-machine.ts b/packages/lint/src/validate-seed-state-machine.ts new file mode 100644 index 0000000000..b762833dab --- /dev/null +++ b/packages/lint/src/validate-seed-state-machine.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time guardrail for seed values that fall outside an object's declared +// state machine (framework#3433 follow-up). +// +// #3433 made seed writes EXEMPT from the `state_machine` validation rule — a +// curated seed is an established fact, so it may be born mid-lifecycle +// (a `completed` project, a `closed_won` opportunity) without the FSM entry +// guard rejecting it. That exemption is deliberate, but it is also a SILENT +// back door: the state machine's "is this value even a state I know about?" +// check no longer runs for seed rows. A field-level `select` still rejects a +// value outside its `options` at write time, so a plain typo is caught there — +// but a `state_machine` on a free-text field, or a value that is a valid option +// yet not a declared FSM state, now sails straight through. +// +// This author-time rule re-adds that safety net WITHOUT re-imposing the FSM: a +// seeded value need not be an initial state (that is the whole point of the +// exemption), but it must be a state the machine DECLARES — the union of +// `initialStates`, the transition-map keys, and the transition targets. +// Anything else is almost certainly a typo or an FSM that forgot to declare the +// state; either way the author should see it before boot. +// +// Advisory (warning): a curated value the FSM does not know about is suspicious +// but not necessarily wrong. Located fix-it, not a hard `os compile` gate — +// symmetric with the replay-safety rule (framework#3434). + +export type SeedStateMachineSeverity = 'warning'; + +export interface SeedStateMachineFinding { + severity: SeedStateMachineSeverity; + rule: string; + /** Human-readable location, e.g. `seed "showcase_project" ("Legacy Sunset")`. */ + where: string; + /** Config path, e.g. `data[4].records[3].status`. */ + path: string; + message: string; + hint: string; +} + +// Rule id (registry entry). +export const SEED_VALUE_OUTSIDE_STATE_MACHINE = 'seed-value-outside-state-machine'; + +type AnyRec = Record; + +interface FsmRule { + field: string; + /** Every state the machine declares: initialStates ∪ transition keys ∪ targets. */ + states: Set; +} + +/** + * Collect the `state_machine` rules (field + full declared-state set) for every + * object, keyed by object name. An object with no such rule contributes nothing. + * The declared-state set is derived from the rule alone so the check does not + * depend on the field's `options` shape (and so it covers free-text state + * fields the enum validator never sees). + */ +function fsmRulesByObject(objects: AnyRec[]): Map { + const map = new Map(); + for (const obj of objects) { + if (!obj || typeof obj !== 'object') continue; + const name = typeof obj.name === 'string' ? obj.name : undefined; + if (!name) continue; + const validations = Array.isArray(obj.validations) ? (obj.validations as AnyRec[]) : []; + const rules: FsmRule[] = []; + for (const v of validations) { + if (!v || typeof v !== 'object' || v.type !== 'state_machine') continue; + const field = typeof v.field === 'string' ? v.field : undefined; + if (!field) continue; + const transitions = + v.transitions && typeof v.transitions === 'object' ? (v.transitions as Record) : {}; + const states = new Set(); + for (const s of Array.isArray(v.initialStates) ? v.initialStates : []) states.add(String(s)); + for (const from of Object.keys(transitions)) { + states.add(String(from)); + const targets = transitions[from]; + for (const to of Array.isArray(targets) ? targets : []) states.add(String(to)); + } + // A state_machine with neither transitions nor initialStates declares no + // states — nothing to check against, so skip it (never flag every value). + if (states.size > 0) rules.push({ field, states }); + } + if (rules.length > 0) map.set(name, rules); + } + return map; +} + +/** Best-effort label for a seed record — its externalId value(s), else its index. */ +function recordLabel(record: AnyRec, externalId: unknown, index: number): string { + const keys = Array.isArray(externalId) + ? (externalId as unknown[]).map(String) + : typeof externalId === 'string' + ? [externalId] + : ['name']; + const parts = keys.map((k) => record[k]).filter((v) => v != null && v !== ''); + return parts.length > 0 ? parts.map(String).join(' · ') : `#${index}`; +} + +/** + * Flag every seed record whose `state_machine`-governed field carries a value + * the machine does not declare (framework#3433 follow-up). Returns the findings + * (empty = clean). The caller decides how to surface them; the CLI folds them in + * as advisory warnings. + * + * Reads `stack.objects` (for the state-machine rules) and `stack.data` (the + * `SeedSchema[]` fixtures). Safe on any shape — a stack with no objects or no + * `data` array yields no findings. A value that is not a plain string (an + * unresolved `cel` Expression envelope, a number) is skipped: it cannot be + * statically compared to the declared-state set. + */ +export function validateSeedStateMachine(stack: AnyRec): SeedStateMachineFinding[] { + const out: SeedStateMachineFinding[] = []; + const objects = Array.isArray(stack.objects) ? (stack.objects as AnyRec[]) : []; + const seeds = Array.isArray(stack.data) ? (stack.data as AnyRec[]) : []; + if (objects.length === 0 || seeds.length === 0) return out; + + const rulesByObject = fsmRulesByObject(objects); + if (rulesByObject.size === 0) return out; + + seeds.forEach((seed, i) => { + if (!seed || typeof seed !== 'object') return; + const objectName = typeof seed.object === 'string' ? seed.object : undefined; + if (!objectName) return; + const rules = rulesByObject.get(objectName); + if (!rules) return; + const records = Array.isArray(seed.records) ? (seed.records as AnyRec[]) : []; + + records.forEach((record, j) => { + if (!record || typeof record !== 'object') return; + for (const rule of rules) { + const value = record[rule.field]; + // Absent / cleared → nothing to check. A non-string (Expression + // envelope, number) can't be compared statically → skip. + if (value == null || value === '') continue; + if (typeof value !== 'string') continue; + if (rule.states.has(value)) continue; + + out.push({ + severity: 'warning', + rule: SEED_VALUE_OUTSIDE_STATE_MACHINE, + where: `seed "${objectName}" (${recordLabel(record, seed.externalId, j)})`, + path: `data[${i}].records[${j}].${rule.field}`, + message: + `seeds '${rule.field}=${value}', which the '${objectName}' state machine does not declare ` + + `(known states: ${[...rule.states].sort().join(', ')}). Seed writes are exempt from the ` + + 'state_machine rule (#3433), so this is NOT rejected at write time — a typo lands silently.', + hint: + `If '${value}' is a real state, add it to the state machine (as an initial state or a ` + + `transition endpoint). If it is a typo, correct it to a declared state. The exemption lets ` + + 'a seed be born mid-lifecycle; it is not a licence to write an unknown state.', + }); + } + }); + }); + + return out; +}