Skip to content

Commit 23c7107

Browse files
feat(state-machine): converge to one enforced state_machine rule (ADR-0020) (#1413)
Make the state_machine validation rule a real write-path guardrail and retire the redundant declaration shapes. Before this, a record state machine could be declared three ways (workflow metadata type, object.stateMachines, or a state_machine validation rule) with zero runtime enforcement. Enforcement (objectql): - New validation/rule-validator.ts: evaluateValidationRules / needsPriorRecord / legalNextStates. state_machine rejects illegal field transitions on update; script/cross_field predicates now fire too (previously silently broken on PATCH). engine.ts plumbs the prior record on single-row update; updateMany logs a warning and skips. Convergence/retirement (spec) — breaking: - Retire the `workflow` metadata type (enum, registry, schema map, collection key, plural mapping). Remove object.stateMachines and stack.workflows. The state_machine validation rule is the one home. - Keep StateMachineSchema (still used by agent lifecycle + protocol) and the workflow RPC service surface as a documented follow-up. Introspection (runtime): - GET /metadata/objects/:name/state/:field?from=:state returns legal next states (null when no FSM, [] for a dead-end). Surfaces: Studio drops the "Workflow Rules" nav; cli explain drops the `workflow` related type. Examples: app-crm workflows migrated — transition table is the opp_stage_transitions state_machine rule; side effects became record-triggered/scheduled Flows. app-showcase predicate conditions corrected to the record.<field> CEL scope. Tests: rule-validator.test.ts (16 cases) + updates to object/ metadata-plugin/metadata-collection/overlay-precedence tests. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 5162da9 commit 23c7107

31 files changed

Lines changed: 897 additions & 334 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/runtime": patch
5+
"@objectstack/platform-objects": patch
6+
"@objectstack/cli": patch
7+
---
8+
9+
ADR-0020 — converge the three "state machine" declaration shapes to one
10+
**enforced** `state_machine` validation rule.
11+
12+
Before this change a record state machine could be declared three ways (a
13+
`workflow` metadata type, an `object.stateMachines` map, or a `state_machine`
14+
validation rule) and **none of them were enforced at runtime** — a declarative
15+
guardrail that was pure decoration, and a hallucination trap for AI authors.
16+
17+
**Enforcement (`@objectstack/objectql`)**
18+
- New `validation/rule-validator.ts` evaluates the object's `validations` union
19+
on the write path: `evaluateValidationRules`, `needsPriorRecord`, and the
20+
`legalNextStates` introspection helper (all exported from the package root).
21+
- `state_machine` rules reject illegal `field` transitions on update (with the
22+
rule's `message`); `script` / `cross_field` predicate rules now also fire
23+
(they were silently broken on PATCH updates because only the patch, not the
24+
prior record, was available). The engine plumbs the prior record into
25+
rule evaluation on single-row update; multi-row (`updateMany`) updates log a
26+
warning and skip rule evaluation rather than enforce on incomplete data.
27+
28+
**Convergence / retirement (`@objectstack/spec`) — breaking**
29+
- Retires the `workflow` metadata type (removed from the metadata-type enum,
30+
the registry, the schema map, the `workflows` collection key, and the
31+
plural→singular mapping).
32+
- Removes the `object.stateMachines` map and the `stack.workflows` array. The
33+
`state_machine` validation rule is the single canonical home.
34+
- The XState-style `StateMachineSchema` file is **kept** (still used by the
35+
agent conversation lifecycle and the discovery protocol); only its role as
36+
the `workflow` metadata-type backing schema was removed. The optional
37+
`workflow` **RPC service** surface (`CoreServiceName.workflow`,
38+
`/api/v1/workflow`, `IWorkflowService`) is kept as a documented follow-up.
39+
40+
**Introspection (`@objectstack/runtime`)**
41+
- Adds `GET /metadata/objects/:name/state/:field?from=:state`, returning the
42+
legal next states for a field (`next: null` when no FSM governs the field,
43+
`[]` for a declared dead-end) so UIs/agents read the transition table instead
44+
of re-deriving it.
45+
46+
**Surfaces (`@objectstack/platform-objects`, `@objectstack/cli`)**
47+
- Studio drops the standalone "Workflow Rules" nav (state machines are edited
48+
alongside the object's other validation rules).
49+
- `explain` no longer lists `workflow` as a related metadata type.
50+
51+
Migration: replace a `workflow` / `StateMachineConfig` declaration with a
52+
`state_machine` validation rule on the object (`field` + `{ from: [allowedTo] }`
53+
transition table), and move any side-effecting actions (emails, task creation)
54+
into a record-triggered or scheduled Flow (ADR-0019). See the migrated
55+
`examples/app-crm` flows for the pattern.

docs/adr/0020-state-machine-converge-and-enforce.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0020: State Machine — converge three declaration forms to one enforced `state_machine`
22

3-
**Status**: Proposed (2026-05-31)
3+
**Status**: Accepted — Implemented (2026-05-31)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0019](./0019-approval-as-flow-node.md) (collapse approval into Flow — "one engine, fold the parasitic concept into its host"; this ADR applies the same principle to the state-machine concept), [ADR-0009](./0009-execution-pinned-metadata.md) (execution-pinned metadata), [ADR-0010](./0010-nl-to-flow-authoring.md) + [ADR-0011](./0011-actions-as-ai-tools.md) (AI authoring is the design center)
66
**Revises**: the #1398 outcome that "reclaimed `workflow` for state machines" — that reclaim left a *name* (`workflow`) and *three* declaration shapes, but no runtime enforcement. This ADR finishes the job.
@@ -130,14 +130,26 @@ The transition declaration stays a flat, recognizable FSM (`field` + `{ from: [t
130130

131131
## Blast radius / implementation checklist
132132

133-
- [ ] `spec`: remove `workflow` from the metadata type enum + registry ([`metadata-plugin.zod.ts:90`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L90), [`:612`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L612)) and the schema map ([`metadata-type-schemas.ts:85`](../../packages/spec/src/kernel/metadata-type-schemas.ts#L85)).
134-
- [ ] `spec`: **canonical home decided — the `state_machine` validation rule** ([`validation.zod.ts:105`](../../packages/spec/src/data/validation.zod.ts#L105)). Remove `object.stateMachines` ([`object.zod.ts:534`](../../packages/spec/src/data/object.zod.ts#L534)); delete the XState `StateMachineSchema` ([`state-machine.zod.ts`](../../packages/spec/src/automation/state-machine.zod.ts)); optionally add an optional per-transition CEL `guard` to `StateMachineValidationSchema`.
135-
- [ ] `spec`: remove/retire `IWorkflowService` ([`workflow-service.ts`](../../packages/spec/src/contracts/workflow-service.ts)) or repurpose it as the enforcement contract.
136-
- [ ] `objectql`: wire the `validations` union into the write path — **plumb the prior/merged record** into rule evaluation (extend [`validateRecord`](../../packages/objectql/src/validation/record-validator.ts#L198) signature or evaluate from a `beforeUpdate` step at [`engine.ts:1850`](../../packages/objectql/src/engine.ts#L1850)), then add `type` dispatch. Fixes `state_machine`, `cross_field`, and `script` together.
137-
- [ ] `metadata-collection.zod.ts`: drop the `workflows: 'workflow'` collection key ([`metadata-collection.zod.ts:117`](../../packages/spec/src/shared/metadata-collection.zod.ts#L117)).
138-
- [ ] `examples/app-crm`: rewrite `src/workflows/*.workflow.ts` — transition rules → `state_machine`; side-effect actions → record-triggered Flow; update `objectstack.config.ts` registration.
139-
- [ ] `examples/app-showcase`: **already carries the surviving shape**`state_machine` rules on `Task`, `Project`, `Account` (added in this design pass, typecheck passing). Keep them as enforcement test fixtures; no new authoring needed.
140-
- [ ] Tests: add write-path tests asserting illegal transitions are rejected and legal ones pass (e.g. `Account` `active → prospect` rejected, `churned → active` allowed).
133+
- [x] `spec`: remove `workflow` from the metadata type enum + registry ([`metadata-plugin.zod.ts`](../../packages/spec/src/kernel/metadata-plugin.zod.ts)) and the schema map ([`metadata-type-schemas.ts`](../../packages/spec/src/kernel/metadata-type-schemas.ts)).
134+
- [x] `spec`: **canonical home — the `state_machine` validation rule** ([`validation.zod.ts`](../../packages/spec/src/data/validation.zod.ts)). Removed `object.stateMachines` ([`object.zod.ts`](../../packages/spec/src/data/object.zod.ts)) and the `stack.workflows` array ([`stack.zod.ts`](../../packages/spec/src/stack.zod.ts)). *(Deviation: kept the `StateMachineSchema` file — see Implementation notes.)*
135+
- [~] `spec`: `IWorkflowService`**kept as a documented follow-up**, not removed (see Implementation notes).
136+
- [x] `objectql`: wire the `validations` union into the write path — new [`rule-validator.ts`](../../packages/objectql/src/validation/rule-validator.ts) (`evaluateValidationRules` / `needsPriorRecord` / `legalNextStates`), with the prior record plumbed into [`engine.ts`](../../packages/objectql/src/engine.ts) on single-row update. Enforces `state_machine`, `cross_field`, and `script` together.
137+
- [x] `metadata-collection.zod.ts`: dropped the `workflows` collection key + `workflows: 'workflow'` plural mapping ([`metadata-collection.zod.ts`](../../packages/spec/src/shared/metadata-collection.zod.ts)).
138+
- [x] `examples/app-crm`: rewrote `src/workflows/*.workflow.ts` — transition tables already live as the `opp_stage_transitions` `state_machine` rule on the opportunity object; side-effect actions became record-triggered / scheduled Flows ([`high-value-deal.flow.ts`](../../examples/app-crm/src/flows/high-value-deal.flow.ts), [`stale-opportunity.flow.ts`](../../examples/app-crm/src/flows/stale-opportunity.flow.ts)); removed the `workflows` registration from `objectstack.config.ts`.
139+
- [x] `examples/app-showcase`: carries the surviving shape — `state_machine` rules on `Task`, `Project`, `Account`. Predicate conditions corrected to the `record.<field>` CEL scope form so enforcement actually fires.
140+
- [x] Tests: [`rule-validator.test.ts`](../../packages/objectql/src/validation/rule-validator.test.ts) (16 cases — allow/reject/no-op transitions, execution-control, predicate fail-open, introspection). Updated `object.test.ts`, `metadata-plugin.test.ts`, `metadata-collection.test.ts`, `overlay-precedence.test.ts` for the retired shapes.
141+
142+
## Implementation notes (deviations from the proposal)
143+
144+
The proposal's intent is fully delivered — converge to the `state_machine` rule, retire the `workflow` metadata type, enforce on the write path — with three **bounded** deviations, all to avoid scope creep into unrelated subsystems:
145+
146+
1. **`StateMachineSchema` (the XState-style schema) is kept, not deleted.** It is still imported by the agent conversation lifecycle ([`ai/agent.zod.ts`](../../packages/spec/src/ai/agent.zod.ts)) and the discovery protocol ([`api/protocol.zod.ts`](../../packages/spec/src/api/protocol.zod.ts)). Only its role as the **`workflow` metadata-type backing schema** and the **`object.stateMachines` / `stack.workflows`** homes were removed. Deleting the schema outright would have churned the agent/protocol surfaces, which is a separate concern.
147+
148+
2. **The `workflow` *RPC service* surface is kept as a follow-up.** `CoreServiceName.workflow`, the `/api/v1/workflow` route catalog (`DEFAULT_WORKFLOW_ROUTES`), and `IWorkflowService` remain — they describe an *unimplemented optional service*, not the retired *metadata type*, and removing them touches the service-discovery contract and its tests. Tracked as a follow-up; the metadata-type retirement (the AI-hallucination hazard this ADR targets) is complete.
149+
150+
3. **No per-transition CEL `guard` was added** to `StateMachineValidationSchema` (it was "optional" in D3/D4). The flat `field` + `transitions` table is enforced as-is; a conditional transition can be expressed today as a sibling `script`/`conditional` rule. Guards can be added later without a breaking change.
151+
152+
**Enforcement scope:** rules run on single-row insert/update through the merged `{...previous, ...patch}` record. Multi-row (`updateMany`) updates **log a warning and skip** rule evaluation rather than silently enforcing on incomplete data — surfaced, not hidden.
141153

142154
## Alternatives considered
143155

examples/app-crm/objectstack.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import * as actions from './src/actions/index.js';
1212
import * as emails from './src/emails/index.js';
1313
import { allHooks } from './src/hooks/index.js';
1414
import { allFlows } from './src/flows/index.js';
15-
import { HighValueDealWorkflow, StaleOpportunityWorkflow } from './src/workflows/index.js';
1615
import {
1716
SalesAssistantAgent,
1817
LookupContactTool,
@@ -105,8 +104,11 @@ export default defineStack({
105104

106105
// Logic
107106
hooks: allHooks,
107+
// ADR-0020: `workflows` retired — record state machines are now a
108+
// `state_machine` validation rule on the object (see
109+
// src/objects/opportunity.object.ts) and side-effecting automation is
110+
// modelled as Flows (high-value-deal, stale-opportunity in allFlows).
108111
flows: allFlows,
109-
workflows: [HighValueDealWorkflow, StaleOpportunityWorkflow],
110112
jobs: [LeadScoringJob, PipelineReportJob, RenewalSweepJob],
111113
emailTemplates: Object.values(emails),
112114

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Flow } from '@objectstack/spec/automation';
4+
5+
/**
6+
* High-Value Deal alert — record-triggered flow.
7+
*
8+
* Migrated from the legacy `workflow` metadata type (retired in ADR-0020,
9+
* which reclaims the runtime FSM as the `state_machine` validation rule on
10+
* the object and folds side-effecting automation into Flow). The original
11+
* rule fired when a `crm_opportunity` crossed the $100k threshold and
12+
* notified sales managers; that side effect is exactly a record-triggered
13+
* Flow.
14+
*
15+
* The lifecycle/transition aspect (open → high_value → won/lost) is no
16+
* longer modelled here — record state transitions belong on the object as a
17+
* `state_machine` validation rule (see opportunity.object.ts). This Flow
18+
* carries only the notification side effect.
19+
*/
20+
export const HighValueDealFlow: Flow = {
21+
name: 'high_value_deal_alert',
22+
label: 'Notify on High-Value Deal',
23+
description: 'Notifies sales managers when an opportunity amount crosses the $100k threshold.',
24+
type: 'autolaunched',
25+
26+
nodes: [
27+
{
28+
id: 'start',
29+
type: 'start',
30+
label: 'On Opportunity Update',
31+
config: {
32+
objectName: 'crm_opportunity',
33+
triggerType: 'record-after-update',
34+
// Fire only on the upward crossing of the threshold, not on every
35+
// save of an already-high-value deal.
36+
condition: 'amount > 100000 && (previous.amount == null || previous.amount <= 100000)',
37+
},
38+
},
39+
{
40+
id: 'notify_managers',
41+
type: 'script',
42+
label: 'Notify Sales Managers',
43+
config: {
44+
actionType: 'email',
45+
inputs: {
46+
to: '{record.owner_manager_email}',
47+
subject: '💰 High-Value Deal: {record.name}',
48+
template: 'high_value_deal_alert',
49+
},
50+
},
51+
},
52+
{ id: 'end', type: 'end', label: 'End' },
53+
],
54+
edges: [
55+
{ id: 'e1', source: 'start', target: 'notify_managers' },
56+
{ id: 'e2', source: 'notify_managers', target: 'end' },
57+
],
58+
};

examples/app-crm/src/flows/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,19 @@ import { LeadQualificationFlow } from './lead-qualification.flow.js';
55
import { RenewalReminderFlow } from './renewal-reminder.flow.js';
66
import { ConvertLeadScreenFlow } from './convert-lead.flow.js';
77
import { DiscountApprovalFlow } from './discount-approval.flow.js';
8+
// ADR-0020: side-effecting automation migrated off the retired `workflow`
9+
// metadata type into record-triggered / scheduled Flows.
10+
import { HighValueDealFlow } from './high-value-deal.flow.js';
11+
import { StaleOpportunityFlow } from './stale-opportunity.flow.js';
812

913
export { ConvertLeadScreenFlow } from './convert-lead.flow.js';
1014

11-
export const allFlows = [OpportunityWonFlow, LeadQualificationFlow, RenewalReminderFlow, ConvertLeadScreenFlow, DiscountApprovalFlow];
15+
export const allFlows = [
16+
OpportunityWonFlow,
17+
LeadQualificationFlow,
18+
RenewalReminderFlow,
19+
ConvertLeadScreenFlow,
20+
DiscountApprovalFlow,
21+
HighValueDealFlow,
22+
StaleOpportunityFlow,
23+
];
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Flow } from '@objectstack/spec/automation';
4+
5+
/**
6+
* Stale Opportunity sweep — scheduled flow.
7+
*
8+
* Migrated from the legacy time-based `workflow` metadata type (retired in
9+
* ADR-0020). The original rule ran on a schedule, found open
10+
* `crm_opportunity` records untouched for 30+ days, and (a) emailed the
11+
* owner and (b) created a follow-up task. A scheduled sweep with
12+
* side-effecting actions is precisely a schedule-triggered Flow, so it now
13+
* lives here rather than as a bespoke workflow metadata type.
14+
*/
15+
export const StaleOpportunityFlow: Flow = {
16+
name: 'stale_opportunity_sweep',
17+
label: 'Stale Opportunity Sweep',
18+
description: 'Daily sweep that notifies owners of open opportunities untouched for 30+ days and opens a follow-up task.',
19+
type: 'schedule',
20+
21+
nodes: [
22+
{
23+
id: 'start',
24+
type: 'start',
25+
label: 'Daily Schedule',
26+
config: {
27+
triggerType: 'schedule',
28+
// Every day at 08:00 — re-evaluate open opportunities for staleness.
29+
cron: '0 8 * * *',
30+
objectName: 'crm_opportunity',
31+
filter: "stage != 'closed_won' && stage != 'closed_lost' && last_modified < daysAgo(30)",
32+
},
33+
},
34+
{
35+
id: 'notify_owner',
36+
type: 'script',
37+
label: 'Notify Owner',
38+
config: {
39+
actionType: 'email',
40+
inputs: {
41+
to: '{record.owner_email}',
42+
subject: '⏰ Stale Opportunity: {record.name}',
43+
template: 'stale_opportunity_alert',
44+
},
45+
},
46+
},
47+
{
48+
id: 'open_followup_task',
49+
type: 'create_record',
50+
label: 'Open Follow-up Task',
51+
config: {
52+
objectName: 'crm_activity',
53+
inputs: {
54+
subject: 'Follow up on stale opportunity: {record.name}',
55+
description: 'This opportunity has not been updated in 30+ days. Please review and update.',
56+
due_date: '{daysFromNow(3)}',
57+
related_to: '{record.id}',
58+
},
59+
},
60+
},
61+
{ id: 'end', type: 'end', label: 'End' },
62+
],
63+
edges: [
64+
{ id: 'e1', source: 'start', target: 'notify_owner' },
65+
{ id: 'e2', source: 'notify_owner', target: 'open_followup_task' },
66+
{ id: 'e3', source: 'open_followup_task', target: 'end' },
67+
],
68+
};

examples/app-crm/src/objects/opportunity.object.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const Opportunity = ObjectSchema.create({
8080
name: 'discount_cap',
8181
label: 'Discount Cap 40%',
8282
description: 'Discounts over 40% require special approval.',
83-
condition: P`discount_percent != null && discount_percent > 40`,
83+
condition: P`record.discount_percent != null && record.discount_percent > 40`,
8484
message: 'Discount cannot exceed 40% without an approved exception.',
8585
severity: 'error' as const,
8686
},
@@ -90,7 +90,7 @@ export const Opportunity = ObjectSchema.create({
9090
label: 'Close Date Must Be Future',
9191
description: 'Prevent setting close_date to a date in the past on new records.',
9292
fields: ['close_date'],
93-
condition: P`has(close_date) && close_date < now()`,
93+
condition: P`has(record.close_date) && record.close_date < now()`,
9494
message: 'Close Date must be today or a future date.',
9595
events: ['insert'],
9696
},

examples/app-crm/src/workflows/high-value-deal.workflow.ts

Lines changed: 0 additions & 57 deletions
This file was deleted.

examples/app-crm/src/workflows/index.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)