Skip to content

Commit 0c302a7

Browse files
os-zhuangclaude
andauthored
fix(objectql): exempt curated seed writes from state_machine validation (#3433) (#3454)
* fix(objectql): exempt curated seed writes from state_machine validation (#3433) `state_machine.initialStates` (#3165) enforced the FSM entry point on every insert, so seed replay silently rejected every mid-lifecycle row and cascaded its master-detail children — an empty-DB showcase boot loaded 1/5 projects and 1/10 tasks, and every marketplace template with a `closed_won`/`closed` seed row plus the rehydrate-heal and per-org replay paths hit the same "installed but no data" trap. A seed is a curated snapshot of established facts, not a record walking its lifecycle, so it is now exempt from the state_machine rule: - spec: new server-set `ExecutionContext.seedReplay` intent flag (sibling of `isSystem`; clients cannot inject it). - objectql: the four `evaluateValidationRules` call sites pass `skipStateMachine` when the context carries `seedReplay`; the rule evaluator skips `state_machine` on both insert (initialStates) and update (transitions). Scoped to state_machine only — format/cross_field/script/json_schema/ conditional still run. - metadata-protocol: `SeedLoaderService.SEED_OPTIONS` sets `seedReplay: true`, so all seed paths (boot inline, marketplace runInlineSeed, per-org replay, http-dispatcher, protocol) are covered with no call-site changes. The showcase project seed drops its three-phase FSM-walk workaround (#3415) and seeds each project directly at its real status again. Regression tests span rule-validator (skip is FSM-scoped), the real engine (seedReplay context bypasses enforcement), and the seed loader (threads the flag, loads all mid-lifecycle rows). Verified on a real empty-DB boot: showcase_project=5, showcase_task=10, no rejected seed rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(showcase): retarget seed-vs-FSM guard to the #3433 exemption; regen exec-context ref doc Two CI follow-ups to the #3433 seed exemption: - examples/app-showcase/test/seed.test.ts — the #3415 guard asserted every seeded status enters through `initialStates`, the exact constraint #3433 removes. Retargeted to the new contract: a seeded value must be a state the FSM declares (not a typo), and the fixture must seed ≥1 non-initial state so a regression back to a planned-only walk fails here. - content/docs/references/kernel/execution-context.mdx — regenerated so the spec reference doc reflects the new `seedReplay` field (generated-artifact drift gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 29ff3c2 commit 0c302a7

12 files changed

Lines changed: 491 additions & 82 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
Exempt curated seed writes from `state_machine` validation (#3433).
8+
9+
A seed is a snapshot of established facts — a project already `completed`, an
10+
opportunity already `closed_won` — not a record walking its lifecycle. But once
11+
an object declared `state_machine.initialStates` (#3165), the write path enforced
12+
the FSM entry point on **every** insert, so seed replay silently rejected every
13+
mid-lifecycle row and cascaded its master-detail children. That is the "installed
14+
but no data" failure for the showcase board (1 of 5 projects), and it would hit
15+
every marketplace template (a `closed_won` opportunity, a `closed` case) plus the
16+
rehydrate-heal and per-org replay paths.
17+
18+
`SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay`
19+
flag; the engine passes `skipStateMachine` to the rule evaluator for those writes,
20+
which skips the `state_machine` rule on both insert (`initialStates`) and update
21+
(transitions). The exemption is scoped to `state_machine` only — a seed must still
22+
satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`,
23+
`conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`,
24+
the fix covers boot inline seed, marketplace install/heal, and per-org replay at once.
25+
26+
The showcase project seed drops its three-phase FSM-walk workaround (#3415) and
27+
seeds each project directly at its real status again.

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const result = ExecutionContext.parse(data);
6767
| **isSystem** | `boolean` || |
6868
| **skipTriggers** | `boolean` | optional | |
6969
| **skipAutomations** | `boolean` | optional | |
70+
| **seedReplay** | `boolean` | optional | |
7071
| **oauthScopes** | `string[]` | optional | |
7172
| **accessToken** | `string` | optional | |
7273
| **transaction** | `any` | optional | |

examples/app-showcase/src/data/objects/project.object.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,7 @@ export const Project = ObjectSchema.create({
136136
active: ['on_hold', 'completed', 'cancelled'],
137137
on_hold: ['active', 'cancelled'],
138138
// `completed → active` is reopen — a real PM affordance (cancelled can
139-
// already be revived via `planned`). It also keeps the seed's FSM walk
140-
// (#3415) replayable: the walk re-runs on every boot, and a dead-end
141-
// terminal state would reject the hop back through `active`.
139+
// already be revived via `planned`), so no status is a dead end.
142140
completed: ['active'],
143141
cancelled: ['planned'],
144142
},

examples/app-showcase/src/data/seed/index.ts

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -101,52 +101,28 @@ const contacts = defineSeed(Contact, {
101101
});
102102

103103
/**
104-
* Projects seed in THREE phases because `project_status_flow` (#3165) gates
105-
* inserts to `initialStates: ['planned']` and seeds deliberately run
106-
* validation. Writing the target status directly rejected 4 of 5 projects on
107-
* every boot — and their master-detail tasks/memberships with them (#3415).
104+
* Projects span every populated status so the Kanban board, Gantt and
105+
* dashboards show a realistic spread on first boot — not just one column.
108106
*
109-
* Phase 1 inserts every project as `planned` — explicitly, because seed
110-
* inserts do NOT apply select defaults (see the Accounts note above) — using
111-
* `mode: 'ignore'` so replays leave already-walked rows completely untouched.
112-
* Phases 2-3 then walk the records along LEGAL transitions, doubling as a
113-
* live demo of the state machine the seed used to violate:
114-
* planned → active (phase 2)
115-
* active → on_hold / completed (phase 3)
116-
* Same-object datasets run in declaration order (stable topological sort).
117-
* On replay, phase 1 skips wholesale (ignore), single-hop rows no-op skip,
118-
* and the two 2-hop rows re-walk `active → terminal` — legal on both edges
119-
* thanks to the `completed → active` reopen transition. Zero rejections.
107+
* `project_status_flow` (#3165) declares `initialStates: ['planned']`, and
108+
* seeds deliberately run validation. But a seed is a curated snapshot of
109+
* ESTABLISHED facts (a project already `active` / `on_hold` / `completed`),
110+
* not a record walking its lifecycle — so the platform exempts seed writes
111+
* from the `state_machine` rule (#3433). That lets each project be seeded
112+
* DIRECTLY at its real status; no FSM-walk workaround (this used to seed all
113+
* five as `planned` then upsert-hop them through legal transitions, #3415).
114+
* `mode: 'upsert'` keeps replay idempotent — an unchanged row no-ops, so
115+
* dev-server restarts and package re-publishes never churn or re-validate.
120116
*/
121117
const projects = defineSeed(Project, {
122-
mode: 'ignore',
123-
externalId: 'name',
124-
records: [
125-
{ 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)` },
126-
{ 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)` },
127-
{ 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)` },
128-
{ 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)` },
129-
{ 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)` },
130-
],
131-
});
132-
133-
const projectsActivate = defineSeed(Project, {
134118
mode: 'upsert',
135119
externalId: 'name',
136120
records: [
137-
{ name: 'Website Relaunch', status: 'active' },
138-
{ name: 'Data Platform', status: 'active' },
139-
{ name: 'Compliance Audit', status: 'active' },
140-
{ name: 'Legacy Sunset', status: 'active' },
141-
],
142-
});
143-
144-
const projectsSettle = defineSeed(Project, {
145-
mode: 'upsert',
146-
externalId: 'name',
147-
records: [
148-
{ name: 'Compliance Audit', status: 'on_hold' },
149-
{ name: 'Legacy Sunset', status: 'completed' },
121+
{ 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)` },
122+
{ 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)` },
123+
{ 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)` },
124+
{ 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)` },
125+
{ 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)` },
150126
],
151127
});
152128

@@ -439,4 +415,4 @@ const announcements = defineSeed(Announcement, {
439415
],
440416
});
441417

442-
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, projectsActivate, projectsSettle, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];
418+
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];

examples/app-showcase/test/seed.test.ts

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,20 @@ describe('showcase stack', () => {
3636
});
3737

3838
/**
39-
* Static shadow of what SeedLoader + validation do at boot (#3415): for every
40-
* object whose state_machine gates INSERT (`initialStates`), replay the seed
41-
* datasets in declaration order and assert each record enters through a legal
42-
* initial state and only moves along declared transitions. The fixture that
43-
* silently lost 4/5 projects (target status written directly on insert) can
44-
* never come back green.
39+
* Static shadow of the seed contract after #3433: a seed write is a curated
40+
* end-state fact, so the platform EXEMPTS it from the object's `state_machine`
41+
* rule — a project is seeded directly `active` / `on_hold` / `completed`
42+
* without walking the FSM up from `planned` (the three-phase walk workaround
43+
* of #3415 is gone). This guard pins the new contract for every object whose
44+
* state_machine gates INSERT (`initialStates`):
45+
* 1. every seeded value is still a state the FSM DECLARES (a curated fact,
46+
* not a typo — the exemption is not a license to write garbage); and
47+
* 2. the fixture actually EXERCISES the exemption by seeding ≥1 non-initial
48+
* state, so a regression back to "all rows enter as the initial state"
49+
* (a re-introduced walk, or a fixture that collapses the board to one
50+
* column) fails here. The #3433 failure was 1/5 projects surviving.
4551
*/
46-
describe('seed data vs state machines (#3415)', () => {
52+
describe('seed data vs state machines (#3433)', () => {
4753
const gated = (stack.objects ?? []).flatMap((o: any) =>
4854
(o.validations ?? [])
4955
.filter(
@@ -56,40 +62,46 @@ describe('seed data vs state machines (#3415)', () => {
5662
.map((v: any) => ({ object: o, rule: v })),
5763
);
5864

59-
it('covers the project status flow (the #3415 gate)', () => {
65+
it('covers the project status flow (the #3433 gate)', () => {
6066
expect(gated.map((g: any) => `${g.object.name}.${g.rule.field}`)).toContain('showcase_project.status');
6167
});
6268

6369
for (const { object, rule } of gated) {
64-
it(`${object.name}: seeded '${rule.field}' respects initialStates and transitions — including on replay`, () => {
70+
it(`${object.name}: seeded '${rule.field}' is FSM-exempt but stays within declared states (#3433)`, () => {
6571
const datasets = ShowcaseSeedData.filter((d: any) => d.object === object.name);
6672
expect(datasets.length).toBeGreaterThan(0);
67-
const current = new Map<string, string>();
68-
// Round 1 = fresh boot; round 2 = replay against the walked state.
69-
// Replay must also be violation-free (#3415 follow-up): `ignore`
70-
// datasets skip existing rows wholesale, and re-walked hops must be
71-
// legal transitions (which is what the reopen edge guarantees).
72-
for (const round of [1, 2]) {
73-
for (const ds of datasets as any[]) {
74-
for (const rec of ds.records as any[]) {
75-
const key = String(rec[ds.externalId ?? 'name']);
76-
const next = rec[rule.field];
77-
if (!current.has(key)) {
78-
// First appearance = INSERT. Seed inserts do NOT apply select
79-
// defaults, so a gated field must be explicit AND legal.
80-
expect(next, `'${key}' (round ${round}) must seed '${rule.field}' explicitly`).toBeDefined();
81-
expect(rule.initialStates, `'${key}' enters as '${next}'`).toContain(next);
82-
current.set(key, next);
83-
} else {
84-
if (ds.mode === 'ignore') continue; // existing rows untouched
85-
if (next === undefined || next === current.get(key)) continue; // no-op replay skips
86-
const from = current.get(key)!;
87-
expect(rule.transitions?.[from] ?? [], `'${key}' (round ${round}) ${from}${next}`).toContain(next);
88-
current.set(key, next);
89-
}
90-
}
73+
74+
// Every state the FSM knows about — the legal value universe, derived
75+
// from the rule itself (no dependency on the field's option shape).
76+
const fsmStates = new Set<string>(
77+
[
78+
...rule.initialStates,
79+
...Object.keys(rule.transitions ?? {}),
80+
...Object.values(rule.transitions ?? {}).flat(),
81+
].map(String),
82+
);
83+
84+
const seeded = new Set<string>();
85+
for (const ds of datasets as any[]) {
86+
for (const rec of ds.records as any[]) {
87+
const v = rec[rule.field];
88+
if (v === undefined || v === null) continue;
89+
const key = String(rec[ds.externalId ?? 'name']);
90+
// #3433: a seed value need NOT be an initialState (the FSM entry
91+
// guard is exempt), but it must be a state the machine declares.
92+
expect(fsmStates, `'${key}' seeds '${rule.field}=${String(v)}'`).toContain(String(v));
93+
seeded.add(String(v));
9194
}
9295
}
96+
97+
// The exemption must actually be used: seed at least one state the FSM
98+
// entry point would reject on INSERT. Guards against a silent regression
99+
// to a planned-only fixture (or a re-introduced FSM walk).
100+
const nonInitial = [...seeded].filter((v) => !rule.initialStates.includes(v));
101+
expect(
102+
nonInitial.length,
103+
`${object.name} seeds only initial states (${[...seeded].join(', ')}) — #3433 exemption unused`,
104+
).toBeGreaterThan(0);
93105
});
94106
}
95107
});

0 commit comments

Comments
 (0)