Skip to content

Commit 3f94261

Browse files
os-zhuangclaude
andcommitted
feat(lint): warn on seed values outside the declared state machine (#3433 follow-up)
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 <noreply@anthropic.com>
1 parent 4340f13 commit 3f94261

9 files changed

Lines changed: 583 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/lint": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(lint): warn on seed values outside an object's declared state machine (#3433 follow-up)
7+
8+
#3433 exempts seed writes from the `state_machine` validation rule, so a seeded
9+
status the FSM does not declare is no longer rejected at write time. A field-level
10+
`select` still catches a value outside its `options`, but a `state_machine` on a
11+
free-text field — or a value that is a valid option yet not a declared FSM state —
12+
now sails through silently: the exemption is a deliberate but blind back door.
13+
14+
`validateSeedStateMachine` (a pure `(stack) => Finding[]` rule, run from
15+
`os validate` / `os lint`, symmetric with the replay-safety rule from #3434)
16+
re-adds that safety net at author time. It flags any seed record whose
17+
`state_machine`-governed field carries a value outside the machine's declared
18+
states — the union of `initialStates`, the transition-map keys, and the transition
19+
targets. Advisory (`warning`): the exemption itself is legitimate, so the fix-it
20+
points at either adding the state to the machine or correcting the typo, not a hard
21+
build failure. New rule id: `seed-value-outside-state-machine`.

content/docs/data-modeling/seed-data.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ It is almost never the right mode: reach for `upsert` (or `ignore`) with a stabl
126126

127127
---
128128

129+
## Seeds and State Machines
130+
131+
A seed is a curated snapshot of **established facts** — a project already
132+
`completed`, an opportunity already `closed_won` — not a record walking its
133+
lifecycle. So a seed write is **exempt from the object's `state_machine` rule**
134+
([#3433](https://github.com/objectstack-ai/objectstack/issues/3433)): it may be
135+
born in any state, and neither `initialStates` (the insert entry guard) nor
136+
`transitions` (the update guard) is enforced. Without this exemption a fixture
137+
that seeds a mid-lifecycle status would be silently rejected on the first boot,
138+
taking its lookup children down with it — the "installed but no data" trap.
139+
140+
The exemption is scoped to the state machine **only**. Every other validation
141+
still runs, so a seeded value must still be a valid field option and pass
142+
`format`, `cross_field`, and the rest. And `os validate` warns when a seeded
143+
value is not a state the machine declares (`seed-value-outside-state-machine`),
144+
so a typo like `'complete'` for `'completed'` still surfaces before boot.
145+
146+
---
147+
129148
## Environment Scoping
130149

131150
The `env` array controls which deployment environments receive the records. The

content/docs/data-modeling/validation.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ Enforce allowed state transitions:
182182

183183
`transitions` governs updates; `initialStates` governs the create. A rule may declare either or both.
184184

185+
**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.
186+
185187
### Cross-Field Validation
186188

187189
Validate relationships between multiple fields:

content/docs/protocol/objectql/state-machine.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ transitions: {
9999
- **On update**, if the state field changed and the new value is **not** in `transitions[oldValue]`, the write is rejected.
100100
- 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.
101101
- Only a rule with `severity: 'error'` (the default) blocks the write; `warning`/`info` are logged.
102+
- **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.
102103

103104
### Conditional transitions
104105

packages/cli/src/commands/lint.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
99
import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js';
1010
import { lintDataModel } from '../lint/data-model-rules.js';
1111
import { validateWidgetBindings } from '@objectstack/lint';
12-
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers, validateSeedReplaySafety } from '@objectstack/lint';
12+
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
1313
import { collectAndLintDocs } from '../utils/collect-docs.js';
1414
import { scoreMetadata } from '../lint/score.js';
1515
import { runMetadataEval } from '../lint/metadata-eval.js';
@@ -455,6 +455,21 @@ export function lintConfig(config: any): LintIssue[] {
455455
});
456456
}
457457

458+
// ── Seed value vs state machine (framework#3433 follow-up) ──
459+
// #3433 exempts seed writes from the `state_machine` rule, so a seeded status
460+
// the FSM does not declare is no longer rejected at write time. Re-add that
461+
// safety net at author time: a value outside the machine's declared states is
462+
// almost certainly a typo. Advisory — the exemption itself is legitimate.
463+
for (const t of validateSeedStateMachine(config)) {
464+
issues.push({
465+
severity: t.severity,
466+
rule: t.rule,
467+
message: `${t.where}: ${t.message}`,
468+
path: t.path,
469+
fix: t.hint,
470+
});
471+
}
472+
458473
return issues;
459474
}
460475

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Marketplace install of a template whose objects declare a `state_machine`
5+
* with `initialStates` MUST NOT drop the mid-lifecycle seed rows (framework#3433).
6+
*
7+
* A marketplace template is a curated snapshot: its seed almost always contains
8+
* rows past the FSM entry point — a `closed_won` opportunity, a `closed` case, a
9+
* `completed` project. #3165 made `initialStates` reject any INSERT outside the
10+
* entry set, which would silently drop every such row on install / rehydrate-heal
11+
* / per-org replay ("installed but no data"). #3433 fixed it at the platform:
12+
* `SeedLoaderService.SEED_OPTIONS` carries `seedReplay`, and the engine skips the
13+
* `state_machine` rule for those writes.
14+
*
15+
* This test drives the REAL marketplace install path (the plugin's HTTP handler
16+
* → dynamic import of the real runtime SeedLoaderService → runInlineSeed) against
17+
* an engine stub that FAITHFULLY reproduces the #3165 guard: an insert whose
18+
* state ∉ `initialStates` is rejected UNLESS the write carries
19+
* `context.seedReplay`. So the assertion below is exactly the #3433 contract on
20+
* the marketplace seam — remove the flag from `SEED_OPTIONS` and this goes red
21+
* (3 of 4 deals dropped, `seeded.errors` > 0).
22+
*/
23+
24+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
25+
import { mkdtempSync, rmSync } from 'node:fs';
26+
import { join } from 'node:path';
27+
import { tmpdir } from 'node:os';
28+
29+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
30+
31+
type Handler = (c: any) => Promise<any>;
32+
33+
function makeRawApp() {
34+
const routes = new Map<string, Handler>();
35+
return {
36+
routes,
37+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
38+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
39+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
40+
};
41+
}
42+
43+
function makeCtx(rawApp: any, services: Record<string, any>) {
44+
const hooks = new Map<string, any>();
45+
return {
46+
ctx: {
47+
hook: (e: string, h: any) => hooks.set(e, h),
48+
getService: (name: string) => {
49+
if (name === 'http-server') return { getRawApp: () => rawApp };
50+
const svc = services[name];
51+
if (svc === undefined) throw new Error(`no ${name}`);
52+
return svc;
53+
},
54+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
55+
},
56+
fire: async () => { await hooks.get('kernel:ready')?.(); },
57+
};
58+
}
59+
60+
function makeC(body: any) {
61+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
62+
return {
63+
req: {
64+
url: 'http://localhost:3000/api/v1/marketplace/install-local',
65+
raw: new Request('http://localhost:3000/x'),
66+
json: async () => body,
67+
param: () => undefined,
68+
header: () => undefined,
69+
},
70+
json,
71+
};
72+
}
73+
74+
/**
75+
* Engine stub that reproduces the #3165 insert-time `initialStates` guard the
76+
* REAL ObjectQL engine applies — and honors the #3433 `seedReplay` exemption.
77+
* Everything else mirrors the faithful stub in the seed-lookup test.
78+
*/
79+
function makeEngine() {
80+
const store: Record<string, any[]> = {};
81+
const registry: Record<string, any> = {};
82+
let idCounter = 0;
83+
84+
const enforceInitialState = (objectName: string, rec: any, opts: any) => {
85+
if (opts?.context?.seedReplay === true) return; // #3433 exemption
86+
const schema = registry[objectName];
87+
const sm = (schema?.validations ?? []).find(
88+
(v: any) => v?.type === 'state_machine' && Array.isArray(v.initialStates),
89+
);
90+
if (!sm) return;
91+
const v = rec?.[sm.field];
92+
if (v == null || v === '') return;
93+
if (!sm.initialStates.includes(String(v))) {
94+
const e: any = new Error(`invalid_initial_state: ${sm.field}='${String(v)}'`);
95+
e.code = 'VALIDATION_FAILED';
96+
throw e;
97+
}
98+
};
99+
100+
const engine: any = {
101+
find: async (objectName: string, query?: any) => {
102+
let records = store[objectName] || [];
103+
if (query?.where) {
104+
records = records.filter((r) =>
105+
Object.entries(query.where).every(([k, v]) => r[k] === v),
106+
);
107+
}
108+
if (typeof query?.limit === 'number') records = records.slice(0, query.limit);
109+
return records;
110+
},
111+
insert: async (objectName: string, data: any, opts?: any) => {
112+
if (!store[objectName]) store[objectName] = [];
113+
if (Array.isArray(data)) {
114+
// Whole-array insert: a bad row throws the batch (the loader's
115+
// bulkWrite then degrades to per-row writeOne, exactly like the
116+
// real engine path).
117+
const records = data.map((d) => {
118+
enforceInitialState(objectName, d, opts);
119+
return { id: `row-${++idCounter}`, ...d };
120+
});
121+
store[objectName].push(...records);
122+
return records;
123+
}
124+
enforceInitialState(objectName, data, opts);
125+
const record = { id: `row-${++idCounter}`, ...data };
126+
store[objectName].push(record);
127+
return record;
128+
},
129+
update: async (objectName: string, data: any) => {
130+
const records = store[objectName] || [];
131+
const idx = records.findIndex((r) => r.id === data.id);
132+
if (idx >= 0) {
133+
records[idx] = { ...records[idx], ...data };
134+
return records[idx];
135+
}
136+
return data;
137+
},
138+
delete: async () => ({ deleted: 1 }),
139+
count: async (objectName: string) => (store[objectName] || []).length,
140+
aggregate: async () => [],
141+
getSchema: (name: string) => registry[name],
142+
syncSchemas: async () => undefined,
143+
registerApp: (manifest: any) => {
144+
for (const obj of manifest?.objects ?? []) {
145+
if (obj?.name) registry[obj.name] = obj;
146+
}
147+
},
148+
};
149+
return { engine, store, registry };
150+
}
151+
152+
/** A template package whose `deal` object gates INSERT to `prospecting`, with a
153+
* seed that deliberately spans the whole pipeline (the marketplace reality). */
154+
const PIPELINE_MANIFEST = {
155+
id: 'app.test.pipeline',
156+
name: 'Pipeline Test',
157+
version: '1.0.0',
158+
objects: [
159+
{
160+
name: 'deal',
161+
label: 'Deal',
162+
fields: {
163+
name: { type: 'text', label: 'Name', required: true },
164+
stage: {
165+
type: 'select',
166+
label: 'Stage',
167+
options: [
168+
{ value: 'prospecting' },
169+
{ value: 'negotiation' },
170+
{ value: 'closed_won' },
171+
{ value: 'closed_lost' },
172+
],
173+
},
174+
},
175+
validations: [
176+
{
177+
type: 'state_machine',
178+
name: 'deal_stage_flow',
179+
field: 'stage',
180+
events: ['insert', 'update'],
181+
initialStates: ['prospecting'],
182+
transitions: {
183+
prospecting: ['negotiation', 'closed_lost'],
184+
negotiation: ['closed_won', 'closed_lost'],
185+
},
186+
message: 'Invalid deal stage.',
187+
},
188+
],
189+
},
190+
],
191+
data: [
192+
{
193+
object: 'deal',
194+
externalId: 'name',
195+
mode: 'upsert',
196+
records: [
197+
{ name: 'Acme Renewal', stage: 'prospecting' }, // the entry state
198+
{ name: 'Globex Expansion', stage: 'negotiation' }, // mid-lifecycle
199+
{ name: 'Initech Migration', stage: 'closed_won' }, // terminal — the killer
200+
{ name: 'Umbrella Deal', stage: 'closed_lost' }, // terminal
201+
],
202+
},
203+
],
204+
};
205+
206+
let dir: string;
207+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-fsm-exempt-')); });
208+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
209+
210+
describe('marketplace install — state_machine initialStates exemption (#3433)', () => {
211+
it('lands every mid-lifecycle seed row (no initialStates rejection on the marketplace seam)', { timeout: 30_000 }, async () => {
212+
const { engine, store, registry } = makeEngine();
213+
const rawApp = makeRawApp();
214+
const { ctx, fire } = makeCtx(rawApp, {
215+
manifest: { register: (m: any) => engine.registerApp(m) },
216+
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
217+
objectql: engine,
218+
metadata: { getObject: vi.fn(async () => undefined), list: vi.fn(async () => []) },
219+
});
220+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
221+
await plugin.start(ctx as any);
222+
await fire();
223+
224+
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
225+
makeC({ manifest: PIPELINE_MANIFEST }),
226+
);
227+
228+
expect(res.payload?.success).toBe(true);
229+
// The object registered (engine registry) and the guard is armed.
230+
expect(registry.deal?.validations?.[0]?.initialStates).toEqual(['prospecting']);
231+
232+
// The inline seed ran and landed EVERY row — including the three that
233+
// start past the FSM entry point. Without the #3433 exemption the stub
234+
// would reject negotiation/closed_won/closed_lost and this is 1, errors > 0.
235+
expect(res.payload?.data?.seeded?.mode).toBe('inline');
236+
expect(res.payload?.data?.seeded?.errors).toBe(0);
237+
expect(store.deal).toHaveLength(4);
238+
expect(store.deal.map((r) => r.stage).sort()).toEqual([
239+
'closed_lost',
240+
'closed_won',
241+
'negotiation',
242+
'prospecting',
243+
]);
244+
});
245+
});

packages/lint/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ export {
126126
} from './validate-seed-replay-safety.js';
127127
export type { SeedReplaySafetyFinding, SeedReplaySafetySeverity } from './validate-seed-replay-safety.js';
128128

129+
export {
130+
validateSeedStateMachine,
131+
SEED_VALUE_OUTSIDE_STATE_MACHINE,
132+
} from './validate-seed-state-machine.js';
133+
export type { SeedStateMachineFinding, SeedStateMachineSeverity } from './validate-seed-state-machine.js';
134+
129135
export {
130136
validateSecurityPosture,
131137
SECURITY_OWD_UNSET,

0 commit comments

Comments
 (0)