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
21 changes: 21 additions & 0 deletions .changeset/seed-state-machine-lint.md
Original file line number Diff line number Diff line change
@@ -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`.
19 changes: 19 additions & 0 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
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 @@ -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

Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>;

function makeRawApp() {
const routes = new Map<string, Handler>();
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<string, any>) {
const hooks = new Map<string, any>();
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<string, any[]> = {};
const registry: Record<string, any> = {};
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',
]);
});
});
6 changes: 6 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading