Chapter 15 — Scenarii and the Three FSMs
A requirement moves on one clock. A feature moves on a second clock. An acceptance criterion moves on a third. One FSM tangles the three clocks. Three FSMs, cross-guarded, keep each clock honest.
A requirements stratum has a time problem before it has anything else. The Requirement "user data shall not be logged in production" was drafted in March, approved in April, and will be maintained for as long as the product lives — its clock is measured in quarters. The Feature redact-logs-middleware was designed in sprint 14, scaffolded in sprint 15, shipped in sprint 17 — its clock is measured in sprint-cycles. The acceptance criterion rejectsObjectsContainingBearerTokens() was declared, scaffolded, implemented and verified inside a single afternoon — its clock is measured in commits.
If you try to model the three clocks with one FSM, you end up with a Cartesian product of states that nobody can draw and nobody can reason about. If you model them with three cooperating FSMs and a small set of cross-FSM guards, each FSM stays small, each state name stays meaningful, and the invariants you care about ("no Critical Feature ships unverified", "no Project releases with a Draft Requirement attached") become three-line predicates.
This chapter is about the three FSMs that @frenchexdev/requirements runs at scenario-playback time: ProjectLifecycleFsm, PerFeatureFsm, PerAcFsm. It is about the @frenchexdev/typed-fsm sibling package that supplies the primitives. It is about requirements scenario play — the CLI command that replays a recorded scenario through the three FSMs. And it is about the three cross-FSM guards that turn a loose hierarchy into a verified, replayable pipeline.
Why three, not one
The case for three small FSMs over one big one is not primarily aesthetic. It is about what changes when, and what can be reasoned about independently.
Consider what would happen if we collapsed everything into a single machine. A state in that machine would have to answer, simultaneously:
- Where is this Requirement in its drafting-to-maintenance cycle?
- Where is every Feature that satisfies this Requirement in its own design-to-ship cycle?
- Where is every AC under every Feature in its declared-to-verified cycle?
A single state name that answered all three would be a tuple, not a state. ProjectSeededWithFeatureScaffoldedAcImplementedButOneFeatureStillAtHasSpec is not a state name a person writes on a whiteboard. It is the kind of composite state that compiles but does not communicate.
The three-FSM decomposition follows the three cadences directly:
- ProjectLifecycleFsm owns the top-level pipeline. One instance per scenario run. States are measured in pipeline phases — "we have drafted requirements; now we are deriving features; now we are scaffolding; now we are implementing". A scenario run takes minutes for a small project, hours for a large one. This clock ticks once per phase transition.
- PerFeatureFsm owns one Feature's lifecycle, from empty spec to verified delivery. One instance per
FeatureSeedin the scenario. States are measured in feature-phases — "we have a spec; we have scaffolded tests; every AC is implemented; the full feature suite passes; compliance reports zero uncovered". This clock ticks once per sprint-level milestone, roughly. - PerAcFsm owns a single AC's lifecycle, from declaration to verification. One instance per AC, so dozens per scenario. States are measured in commit-phases — "declared; scaffolded; implemented by AI or stub; test passes; reviewed; verified". This clock ticks fast, sometimes multiple times per hour during active implementation.
Three clocks, three machines. Each machine is small — eight states for the project, seven for the feature, seven for the AC. Each machine is drawable on an index card. Each machine's transitions are testable in isolation.
The glue between them is not shared mutable state. It is a set of cross-FSM guards — predicates that one FSM's transition consults from the state of its sibling FSMs — plus an event bus that carries AcImplemented, TestsPass, AllAcsDone and a handful of similar domain events upward. The guards are pure functions of the collective state; the bus is append-only; the whole thing is replayable from an event log.
The primitives for this — @FiniteStateMachine, @State, @Transition, the StateMachineGraph extractor — live in the sibling package @frenchexdev/typed-fsm. The orchestration logic and the cross-FSM guards live in src/cli/scenario/scenario-orchestrator.ts inside @frenchexdev/requirements itself. The boundary is clear: typed-fsm supplies the vocabulary; requirements supplies the grammar of coordination.
ProjectLifecycleFsm
The top-level machine. One instance per scenario run. Declared in scenario-orchestrator.ts, owned by the orchestrator, driven by the scenario-player loop. Its job is to thread the scenario playback through the eight phases the pipeline needs to visit.
The state set is fixed:
Empty— no scenario loaded yet. Initial state.Seeded— scenario JSON has been read, schema-validated, and forward references (a requirement seed pointing at a feature seed id that does not exist) have been resolved.RequirementsDrafted— everyRequirementSeedhas been replayed throughrunRequirementWizard(..., { yes: true })and the resultingRequirementclass file has been written torequirements/requirements/.FeaturesDerived— everyFeatureSeedhas been replayed throughrunFeatureWizard(..., { yes: true })and the resultingFeatureclass file has been written torequirements/features/.ScaffoldingReady— tests have been scaffolded for every declaredAC × TestLevelcombination viagenerateScaffoldForSpecacross the seven-level registry.Implementing— NPerAcFsminstances have been forked, one per AC, and the orchestrator is waiting on anAllAcsDoneevent.Verifying— everyPerAcFsmis in a terminal state (success or failure) andcompliance --strictis running on the generated project.Published— terminal success state. Compliance passed; every expected artifact exists; the scenario'soutronarration has fired.
Plus one non-success terminal:
Failed— terminal failure. Reachable from any non-terminal state via aFail(reason)event. The reason is appended to the replay log.
The event set is equally fixed. In the orchestrator, every transition is declared as a @Transition({ from, to, on }) annotation on a method whose body performs the entry action:
import { FiniteStateMachine, State, Transition } from '@frenchexdev/typed-fsm/fsm';
import { Feature, Priority, type ACResult } from '@frenchexdev/typed-fsm';
@FiniteStateMachine({ features: [ScenarioOrchestratorFeature] })
export class ProjectLifecycleFsm {
@State initial = 'Empty';
@Transition({ from: 'Empty', to: 'Seeded', on: 'LoadScenario' })
loadScenario(path: string): void {
const raw = this.fs.readFileSync(path, 'utf8');
const parsed = JSON.parse(raw);
this.schema.validate(parsed);
this.seeds = resolveForwardReferences(parsed);
this.narration.intro(parsed.narration);
}
@Transition({ from: 'Seeded', to: 'RequirementsDrafted', on: 'GenerateRequirements' })
generateRequirements(): void {
for (const seed of this.seeds.requirements) {
runRequirementWizard(seed, { yes: true });
}
}
@Transition({ from: 'RequirementsDrafted', to: 'FeaturesDerived', on: 'DeriveFeatures' })
deriveFeatures(): void {
for (const seed of this.seeds.features) {
runFeatureWizard(seed, { yes: true });
}
}
@Transition({ from: 'FeaturesDerived', to: 'ScaffoldingReady', on: 'Scaffold' })
scaffold(): void {
for (const spec of this.seeds.features) {
generateScaffoldForSpec(spec);
}
}
@Transition({ from: 'ScaffoldingReady', to: 'Implementing', on: 'Implement' })
implement(): void {
this.perAcFsms = this.seeds.features
.flatMap((f) => f.acs.map((ac) => this.orchestrator.spawnPerAcFsm(f, ac)));
}
@Transition({ from: 'Implementing', to: 'Verifying', on: 'AllAcsDone' })
allAcsDone(): void {
/* no entry action — transition only */
}
@Transition({ from: 'Verifying', to: 'Published', on: 'Verify' })
verify(): void {
const report = this.compliance.runStrict();
assertExpectedArtifacts(this.seeds.targets.expectedArtifacts);
this.narration.outro(this.seeds.narration);
if (!report.ok) throw new ComplianceFailure(report);
}
}import { FiniteStateMachine, State, Transition } from '@frenchexdev/typed-fsm/fsm';
import { Feature, Priority, type ACResult } from '@frenchexdev/typed-fsm';
@FiniteStateMachine({ features: [ScenarioOrchestratorFeature] })
export class ProjectLifecycleFsm {
@State initial = 'Empty';
@Transition({ from: 'Empty', to: 'Seeded', on: 'LoadScenario' })
loadScenario(path: string): void {
const raw = this.fs.readFileSync(path, 'utf8');
const parsed = JSON.parse(raw);
this.schema.validate(parsed);
this.seeds = resolveForwardReferences(parsed);
this.narration.intro(parsed.narration);
}
@Transition({ from: 'Seeded', to: 'RequirementsDrafted', on: 'GenerateRequirements' })
generateRequirements(): void {
for (const seed of this.seeds.requirements) {
runRequirementWizard(seed, { yes: true });
}
}
@Transition({ from: 'RequirementsDrafted', to: 'FeaturesDerived', on: 'DeriveFeatures' })
deriveFeatures(): void {
for (const seed of this.seeds.features) {
runFeatureWizard(seed, { yes: true });
}
}
@Transition({ from: 'FeaturesDerived', to: 'ScaffoldingReady', on: 'Scaffold' })
scaffold(): void {
for (const spec of this.seeds.features) {
generateScaffoldForSpec(spec);
}
}
@Transition({ from: 'ScaffoldingReady', to: 'Implementing', on: 'Implement' })
implement(): void {
this.perAcFsms = this.seeds.features
.flatMap((f) => f.acs.map((ac) => this.orchestrator.spawnPerAcFsm(f, ac)));
}
@Transition({ from: 'Implementing', to: 'Verifying', on: 'AllAcsDone' })
allAcsDone(): void {
/* no entry action — transition only */
}
@Transition({ from: 'Verifying', to: 'Published', on: 'Verify' })
verify(): void {
const report = this.compliance.runStrict();
assertExpectedArtifacts(this.seeds.targets.expectedArtifacts);
this.narration.outro(this.seeds.narration);
if (!report.ok) throw new ComplianceFailure(report);
}
}(The Fail(reason) self-transition from any state to Failed is omitted here for readability; it is declared once and applies uniformly.)
Three things are worth noting about this shape.
First, the entry actions are where side effects live. The machine itself is pure state. But the @Transition method body is permitted to do disk I/O, network I/O (through the AiAdapter port only, per REQ-AI-AS-IMPLEMENTER-ADAPTER), and subprocess calls (vitest, compliance scanner). The orchestrator guarantees that entry actions fire exactly once per transition; replay reruns them exactly.
Second, the guards — the preconditions on a transition firing — are embedded in the event-handler dispatch layer, not in the body of the entry action. For ProjectLifecycleFsm, most guards are trivial ("the event name matches the expected one for this state"). But the GenerateRequirements transition has a non-trivial guard: seeds.requirements.length > 0. A scenario that declares zero requirements but some features would skip from Seeded directly to FeaturesDerived — the machine's declared transitions do not support that skip; the scenario validator must reject such a scenario at Seeded → Failed time, before any side effect.
Third, Priority and @Refines modify the semantics of the Verifying → Published transition. A scenario publishing a project where a Priority.Critical Requirement is still in status: 'Draft' cannot reach Published. A child Requirement (declared with @Refines(ParentRequirement)) cannot be Approved before its parent has been Approved. These constraints are not states of ProjectLifecycleFsm; they are guards on the Verify transition that consult the state of every Requirement instance in the project. We will look at them in the Cross-FSM guards section below.
The existing scenario design doc in the repo carries the full transition table, including the guard column and the entry-action column, for all eight transitions. The CLI commands that drive the FSM (scenario play, scenario record, scenario inspect) each dispatch to the orchestrator through the same typed event bus; the FSM does not care who originated the event, only that the event is well-formed and fires from a valid state.
PerFeatureFsm
Slave to ProjectLifecycleFsm. One instance per FeatureSeed. Its job is to aggregate the completion of all PerAcFsm instances under its Feature into a single per-feature verdict.
The state set is:
Empty— spawned when the parent entersFeaturesDerived, not yet seeded. Initial state.HasSpec— the Feature class file has been written byrunFeatureWizard; the spec is on disk.HasScaffold— tests have been scaffolded for every declaredAC × TestLevelcombination under this Feature.AllAcsImplemented— every childPerAcFsmhas reached at leastTestPassing.AllTestsPassing— the full per-Feature vitest suite exits with code 0.Verified— compliance reports zero uncovered ACs for this Feature. Terminal success.Failed— terminal failure.
The event set:
| Event | From → To | Guard |
|---|---|---|
SpecWritten |
Empty → HasSpec |
runFeatureWizard returned a valid spec file |
ScaffoldWritten |
HasSpec → HasScaffold |
every declared AC × TestLevel produced a file |
AcImplemented(acName) |
HasScaffold → AllAcsImplemented |
implementedCount === feature.acs.length |
TestsPass |
AllAcsImplemented → AllTestsPassing |
vitest exit code 0 for this Feature's tests |
Verified |
AllTestsPassing → Verified |
compliance reports zero uncovered ACs for this Feature |
Fail(reason) |
any → Failed |
— |
Two of these guards are worth unpacking.
The guard on AcImplemented is a count equality: implementedCount === feature.acs.length. This is where the per-AC → per-Feature aggregation happens. Each PerAcFsm, when it enters TestPassing, fires AcImplemented(acName) on the event bus. The PerFeatureFsm listens for these events, maintains an internal Set<string> of the AC names it has seen, and transitions from HasScaffold to AllAcsImplemented only when the set cardinality matches the feature's declared AC count. Missing ACs keep the machine in HasScaffold; extra ACs (ACs fired for names not in the declared list) are a bug and trip Fail.
The guard on Verified is the compliance-zero guard. Compliance is run against the whole project at the end of ProjectLifecycleFsm.Verifying, but each PerFeatureFsm listens for the report and checks the uncovered-AC count filtered to its own Feature id. Zero uncovered → Verified. Non-zero → Fail(reason) with the list of uncovered AC names.
Entry actions in PerFeatureFsm are strictly bookkeeping — updating counters, appending to the replay log, maybe emitting a narration line. The side effects (file writes, test runs, compliance scans) are owned by ProjectLifecycleFsm (for project-level actions) and by PerAcFsm (for per-AC actions). This is a deliberate separation: the middle tier exists only to aggregate. Giving it side effects would duplicate the work and make replay non-deterministic.
The running example makes this concrete. FEATURE-TRACE-EXPLORER-TUI declares, today, ten acceptance criteria (four drill-down ACs, two safety ACs, two port ACs, two integration ACs, plus one end-to-end AC — the counts do not quite match my head; let me pull them from the file):
abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;abstract traceExplorerBuildsGraph(): ACResult;
abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;
abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;
abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
abstract traceExplorerUsesPromptPortForInteraction(): ACResult;
abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;Ten ACs. Its PerFeatureFsm instance, today — April 2026 — sits at HasScaffold. The Feature class file is on disk (HasSpec reached long ago). Every AC has a scaffolded test file with @FeatureTest(FeatureTraceExplorerTuiFeature) and @Verifies('traceExplorerBuildsGraph') in place. None of the ten tests yet has a non-stub body, so implementedCount === 0 !== 10, and the machine cannot advance to AllAcsImplemented.
Contrast with NEW-COMMAND (the requirements feature new wizard, shipped months ago): enabled: true, Priority.High, every AC implemented, every test green. Its PerFeatureFsm sits at Verified, terminal. If you ran scenario play on the scenario that built NEW-COMMAND, the PerFeatureFsm would reach Verified and then idle — no further events affect a terminal state.
The symmetry is exactly what you want. The two Features are at different lifecycle positions; the FSM reflects it; the scanner can colour them differently in the trace-explorer TUI; the compliance reporter can filter its output by PerFeatureFsm state. The same structural primitive — state — answers lifecycle questions at the Feature tier without polluting Feature itself with lifecycle fields.
PerAcFsm
The finest-grained of the three. One instance per acceptance criterion. Its job is to walk a single AC from declaration through implementation to verification, with a retry loop for transient test failures and a review gate before terminal success.
The state set:
Declared— the AC exists in the Feature class (as an abstract method). Initial state.Scaffolded— a test file exists undertest/with@FeatureTest(Feature)at the class level and@Verifies('acName')at the method level. The method body is a stub (throw new Error('not yet implemented')).ImplementedByAI— the stub has been replaced with a concrete test implementation. The source of the implementation is either theAiAdapter(claude-api, prompt caching on) or a deterministic stub, chosen by the--aiflag onscenario play.TestPassing— the scaffolded test runs and exits with code 0 against the implementation. FiresAcImplemented(acName)on the bus, which its parentPerFeatureFsmconsumes.Reviewed— a human or automated reviewer has approved the AC. The event carries a{reviewer, approved}payload;approved === falseroutes toFailedinstead.Verified— terminal success.Failed— terminal failure.
The event set:
| Event | From → To | Guard | Entry action |
|---|---|---|---|
ScaffoldEmitted |
Declared → Scaffolded |
file written | — |
AiImplement(prompt) |
Scaffolded → ImplementedByAI |
--ai flag set |
AiAdapter.implementAc(prompt) |
ImplementStub |
Scaffolded → ImplementedByAI |
--ai flag not set |
write deterministic throw new Error('not yet implemented') |
TestRunPass |
ImplementedByAI → TestPassing |
vitest exit 0 | fire parent AcImplemented(acName) |
TestRunFail(retry?) |
ImplementedByAI → ImplementedByAI or → Failed |
retry && attempts < max |
re-invoke adapter with failure context |
Review({reviewer, approved}) |
TestPassing → Reviewed |
approved === true |
— |
Verified |
Reviewed → Verified |
— | — |
Fail(reason) |
any → Failed |
— | — |
Three shapes here deserve attention.
First, the branching at Scaffolded. Two events lead to ImplementedByAI — one that delegates to the AI adapter, one that writes a deterministic stub. The branching key is the --ai flag on scenario play. In --ai mode, the adapter produces a candidate implementation; the stub is unused. In non---ai mode, the stub is written and the test is expected to fail (at TestRunFail, no retry, into Failed) — this is how a scenario can be replayed deterministically without an AI key, producing a project that has correct scaffolding and failing tests, ready for a human implementer.
Second, the retry self-loop at ImplementedByAI. TestRunFail can take two routes: back to ImplementedByAI if retry && attempts < max, or forward to Failed otherwise. The attempts counter is not FSM-internal mutable state; it is derived from the event log — the number of TestRunFail events fired on this AC's machine. The retry loop re-invokes the adapter with the failure context (the test output, the compiler error, the stack trace) prepended to the prompt. Per REQ-AI-AS-IMPLEMENTER-ADAPTER, the adapter uses Anthropic's prompt caching to keep the context window affordable across retries.
Third, the review gate. TestPassing → Reviewed → Verified is two transitions, not one. The review is not just rubber-stamp; a scenario can declare a reviewer policy (auto-approve-if-coverage-100, require-human-approval-for-critical-priority, defer-to-ci-bot) and the orchestrator consults the policy when firing the Review event. In CI mode, the review step is elided (policy: auto-approve-if-compliance-strict-passes) and TestPassing flows directly to Verified through a synthetic Review({reviewer: 'ci', approved: true}) event.
The scanner that feeds the trace-explorer TUI runs outside the FSM but observes it. It reads vitest output, the compliance report, and git log, and infers each AC's current PerAcFsm state. The inferred state is attached to the AC node in the traceability graph, which means the TUI can render an AC as Scaffolded (grey), ImplementedByAI (yellow), TestPassing (green, no crown), Verified (green with crown), or Failed (red). The colours are an artifact; the state machine is the contract.
Scenario replay
A scenario is not a recording of FSM states. It is a recording of FSM events.
The distinction is load-bearing. If a scenario recorded states, a small change to the FSM (adding a state, splitting one state into two) would invalidate every scenario ever recorded. If a scenario records events, the events survive schema evolution — as long as the event vocabulary is backward-compatible, old scenarios replay unchanged against new FSMs, and the test suite that exercises the replay layer catches any break.
A scenario file, on disk, is a JSON document validated against scenario.schema.json. Its top-level shape is:
interface Scenario {
version: '1';
narration: { intro: string; outro: string; stepDelayMs?: number };
seeds: {
requirements: RequirementSeed[];
features: FeatureSeed[];
};
targets: {
expectedArtifacts: string[];
compliance: { mode: 'strict' | 'warn' };
};
events: ScenarioEvent[];
}interface Scenario {
version: '1';
narration: { intro: string; outro: string; stepDelayMs?: number };
seeds: {
requirements: RequirementSeed[];
features: FeatureSeed[];
};
targets: {
expectedArtifacts: string[];
compliance: { mode: 'strict' | 'warn' };
};
events: ScenarioEvent[];
}The events array is the recording. Each entry is a typed event with a timestamp (relative to scenario start), a target FSM instance key (project, feature:TRACE-EXPLORER-TUI, ac:TRACE-EXPLORER-TUI:traceExplorerBuildsGraph), and an event name with payload:
type ScenarioEvent =
| { t: number; target: 'project'; event: 'LoadScenario'; path: string }
| { t: number; target: 'project'; event: 'GenerateRequirements' }
| { t: number; target: 'project'; event: 'DeriveFeatures' }
| { t: number; target: 'project'; event: 'Scaffold' }
| { t: number; target: 'project'; event: 'Implement' }
| { t: number; target: 'project'; event: 'AllAcsDone' }
| { t: number; target: 'project'; event: 'Verify' }
| { t: number; target: `feature:${string}`; event: 'SpecWritten' }
| { t: number; target: `feature:${string}`; event: 'ScaffoldWritten' }
| { t: number; target: `ac:${string}:${string}`; event: 'ScaffoldEmitted' }
| { t: number; target: `ac:${string}:${string}`; event: 'AiImplement'; prompt: string }
| { t: number; target: `ac:${string}:${string}`; event: 'ImplementStub' }
| { t: number; target: `ac:${string}:${string}`; event: 'TestRunPass' }
| { t: number; target: `ac:${string}:${string}`; event: 'TestRunFail'; retry: boolean }
| { t: number; target: `ac:${string}:${string}`; event: 'Review'; reviewer: string; approved: boolean };type ScenarioEvent =
| { t: number; target: 'project'; event: 'LoadScenario'; path: string }
| { t: number; target: 'project'; event: 'GenerateRequirements' }
| { t: number; target: 'project'; event: 'DeriveFeatures' }
| { t: number; target: 'project'; event: 'Scaffold' }
| { t: number; target: 'project'; event: 'Implement' }
| { t: number; target: 'project'; event: 'AllAcsDone' }
| { t: number; target: 'project'; event: 'Verify' }
| { t: number; target: `feature:${string}`; event: 'SpecWritten' }
| { t: number; target: `feature:${string}`; event: 'ScaffoldWritten' }
| { t: number; target: `ac:${string}:${string}`; event: 'ScaffoldEmitted' }
| { t: number; target: `ac:${string}:${string}`; event: 'AiImplement'; prompt: string }
| { t: number; target: `ac:${string}:${string}`; event: 'ImplementStub' }
| { t: number; target: `ac:${string}:${string}`; event: 'TestRunPass' }
| { t: number; target: `ac:${string}:${string}`; event: 'TestRunFail'; retry: boolean }
| { t: number; target: `ac:${string}:${string}`; event: 'Review'; reviewer: string; approved: boolean };The template literal types feature:${string} and ac:${string}:${string} are a small but load-bearing piece of the DSL. They push the target-key structure into the type system, so a scenario that sends a feature event to the project FSM, or an AC event to a feature FSM, is a type error in the scenario authoring pipeline. (Scenarios can be hand-written in TypeScript and compiled to JSON; they can also be recorded by requirements scenario record from a live wizard session.)
The replay command:
npx requirements scenario play requirements/scenarii/bootstrap-trace-explorer.scenario.jsonnpx requirements scenario play requirements/scenarii/bootstrap-trace-explorer.scenario.jsonwalks the three FSMs through the recorded events. For each event, it:
- Looks up the target FSM instance (creating sub-FSMs lazily as their parents transition into the right state — e.g., PerAcFsms are created during the
Implemententry action, not earlier). - Checks the event's from-state against the target FSM's current state. If they don't match, the replay fails with a
UnexpectedEventErrorcarrying the event's target, expected from-state, actual state, and event index. - Evaluates any guards on the transition. If a guard fails, replay fails with
GuardFailureError. - Fires the transition, running its entry action.
- Appends the event to the orchestrator's replay log.
- Waits
narration.stepDelayMsif set (for human-observable playback), otherwise advances immediately.
At the end of the event stream, the orchestrator asserts:
ProjectLifecycleFsmis inPublished.- Every
PerFeatureFsmis inVerified. - Every
PerAcFsmis inVerified. - Every path in
targets.expectedArtifactsexists. compliance --strictpasses.
If all five hold, the replay passes. If any fails, replay fails with a structured error that carries enough information to regenerate the scenario's expected state.
This gives us regression testing of lifecycle logic itself. A scenario recorded today that produces NEW-COMMAND end-to-end can be committed to the repo and replayed in CI. If a refactor to PerAcFsm introduces a bug where TestRunPass sometimes does not fire AcImplemented, the replay will halt at a UnexpectedEventError at some later event whose precondition required AllAcsImplemented. The error points at the bug without requiring a human to reason about the whole pipeline.
Per REQ-PARALLEL-DELIVERABLE, the Scenario stream is its own first-class deliverable — a stream of committed .scenario.json files under requirements/scenarii/, each traceable back to the Requirement it was built to demonstrate. Early scenarios in the repo demonstrate trivial pipelines (one Requirement, one Feature, three ACs). Later scenarios demonstrate the full dog-food loop — including a scenario that regenerates @frenchexdev/requirements itself from scratch, producing a byte-identical package.
Cross-FSM guards
Three invariants bind the three FSMs into a coherent whole. Each invariant is a cross-FSM guard — a predicate that one FSM's transition consults against the state of its sibling FSMs.
Invariant 1 — an AC cannot be Verified before its Feature is HasScaffold or later.
A PerAcFsm attempting to transition to Verified consults its parent PerFeatureFsm's state. If the parent is Empty or HasSpec, the guard rejects the transition. In practice, this guard rarely fires — an AC's scaffolding is what causes the Feature to transition to HasScaffold in the first place — but it catches a class of bugs where a malformed scenario reviews an AC before its test was ever written.
@Transition({ from: 'Reviewed', to: 'Verified', on: 'Verified' })
verify(): void {
const parent = this.orchestrator.findPerFeature(this.featureId);
if (parent.state === 'Empty' || parent.state === 'HasSpec') {
throw new CrossFsmGuardFailure(
`AC ${this.acName} cannot reach Verified while Feature ${this.featureId} is at ${parent.state}`
);
}
/* transition proceeds */
}@Transition({ from: 'Reviewed', to: 'Verified', on: 'Verified' })
verify(): void {
const parent = this.orchestrator.findPerFeature(this.featureId);
if (parent.state === 'Empty' || parent.state === 'HasSpec') {
throw new CrossFsmGuardFailure(
`AC ${this.acName} cannot reach Verified while Feature ${this.featureId} is at ${parent.state}`
);
}
/* transition proceeds */
}Invariant 2 — a Feature cannot be AllTestsPassing until every AC under it is TestPassing or later.
The PerFeatureFsm consults the event log (not the live PerAcFsm states, for replay determinism) when processing TestsPass. If any declared AC for this Feature has not yet fired AcImplemented, the guard fails.
@Transition({ from: 'AllAcsImplemented', to: 'AllTestsPassing', on: 'TestsPass' })
testsPass(): void {
const implementedAcs = this.replayLog.filterAcImplementedFor(this.featureId);
const declaredAcs = this.feature.acs.map((ac) => ac.name);
const missing = declaredAcs.filter((n) => !implementedAcs.has(n));
if (missing.length > 0) {
throw new CrossFsmGuardFailure(
`Feature ${this.featureId} cannot reach AllTestsPassing; missing ACs: ${missing.join(', ')}`
);
}
/* transition proceeds */
}@Transition({ from: 'AllAcsImplemented', to: 'AllTestsPassing', on: 'TestsPass' })
testsPass(): void {
const implementedAcs = this.replayLog.filterAcImplementedFor(this.featureId);
const declaredAcs = this.feature.acs.map((ac) => ac.name);
const missing = declaredAcs.filter((n) => !implementedAcs.has(n));
if (missing.length > 0) {
throw new CrossFsmGuardFailure(
`Feature ${this.featureId} cannot reach AllTestsPassing; missing ACs: ${missing.join(', ')}`
);
}
/* transition proceeds */
}Invariant 3 — the project cannot be Published unless every Priority.Critical Feature is Verified, and every Priority.Critical Requirement has status: 'Approved'.
This is the most consequential guard. It encodes the release policy in a three-line predicate. A scenario that tries to publish a project where a Critical Feature is still Failed, or a Critical Requirement is still Draft, halts at Verifying → Failed rather than Verifying → Published.
@Transition({ from: 'Verifying', to: 'Published', on: 'Verify' })
verify(): void {
const criticalFeatures = this.registry.getFeatures().filter((f) => f.priority === Priority.Critical);
const unverified = criticalFeatures.filter((f) => {
const fsm = this.orchestrator.findPerFeature(f.id);
return fsm.state !== 'Verified';
});
if (unverified.length > 0) {
throw new CrossFsmGuardFailure(
`Project cannot reach Published; ${unverified.length} Critical Features not Verified: ` +
unverified.map((f) => f.id).join(', ')
);
}
const criticalReqs = this.registry.getRequirements().filter((r) => r.priority === Priority.Critical);
const unapproved = criticalReqs.filter((r) => r.status !== 'Approved');
if (unapproved.length > 0) {
throw new CrossFsmGuardFailure(
`Project cannot reach Published; ${unapproved.length} Critical Requirements not Approved: ` +
unapproved.map((r) => r.id).join(', ')
);
}
/* compliance, artifacts, narration, then transition */
}@Transition({ from: 'Verifying', to: 'Published', on: 'Verify' })
verify(): void {
const criticalFeatures = this.registry.getFeatures().filter((f) => f.priority === Priority.Critical);
const unverified = criticalFeatures.filter((f) => {
const fsm = this.orchestrator.findPerFeature(f.id);
return fsm.state !== 'Verified';
});
if (unverified.length > 0) {
throw new CrossFsmGuardFailure(
`Project cannot reach Published; ${unverified.length} Critical Features not Verified: ` +
unverified.map((f) => f.id).join(', ')
);
}
const criticalReqs = this.registry.getRequirements().filter((r) => r.priority === Priority.Critical);
const unapproved = criticalReqs.filter((r) => r.status !== 'Approved');
if (unapproved.length > 0) {
throw new CrossFsmGuardFailure(
`Project cannot reach Published; ${unapproved.length} Critical Requirements not Approved: ` +
unapproved.map((r) => r.id).join(', ')
);
}
/* compliance, artifacts, narration, then transition */
}The three guards together ensure that the three clocks cannot desynchronise in ways that produce an incoherent release. A Low-priority Feature can ship Failed — the project still reaches Published. A Critical Feature cannot. A Draft Critical Requirement forbids release. These are business rules; they happen to be expressible as one-screen predicates because the FSM decomposition gave each rule a natural home.
Diagram — the three-FSM stack
Caption: The three-FSM stack. Solid arrows are in-FSM transitions; dotted arrows are cross-FSM events (upward) and cross-FSM guards (downward). ProjectLifecycleFsm spawns one PerFeatureFsm per Feature in FeaturesDerived, and one PerAcFsm per AC in Implementing. Each PerAcFsm completion fires AcImplemented upward; the aggregated event reaches ProjectLifecycleFsm.Verifying. The downward arrows — guards — ensure a Feature cannot reach AllTestsPassing without its ACs at Verified or later, and the project cannot reach Published without every Critical Feature at Verified.
Alt: A flowchart with three horizontal swim-lanes. The top lane is ProjectLifecycleFsm with eight states running left to right from Empty to Published. The middle lane is PerFeatureFsm with six states from Empty to Verified. The bottom lane is PerAcFsm with six states from Declared to Verified. Dotted arrows cross between lanes: downward arrows labelled "spawns" going from FeaturesDerived to the Feature lane's Empty and from Implementing to the AC lane's Declared; upward arrows labelled "fires AcImplemented" and "fires TestsPass" going from AC TestPassing to Feature AllAcsImplemented and from Feature AllTestsPassing to Project Verifying; and downward arrows labelled "guard consults" going from Project Verifying and Feature AllTestsPassing to the layer below.
Diagram — ProjectLifecycleFsm in isolation
Caption: ProjectLifecycleFsm — eight success states plus a terminal Failed. The success path is strictly linear: each state has exactly one outgoing success transition, and the event that triggers it names the next state. The Fail(reason) event is the only branching point, and it can fire from any non-terminal state.
Alt: State diagram in mermaid stateDiagram-v2 showing the linear progression from an initial arrow into Empty, then Empty to Seeded via LoadScenario, Seeded to RequirementsDrafted via GenerateRequirements, RequirementsDrafted to FeaturesDerived via DeriveFeatures, FeaturesDerived to ScaffoldingReady via Scaffold, ScaffoldingReady to Implementing via Implement, Implementing to Verifying via AllAcsDone, Verifying to Published via Verify, Published to a terminal arrow, plus a Fail transition from every non-terminal state to a Failed state which also terminates.
Diagram — PerAcFsm in isolation
Caption: PerAcFsm — one instance per acceptance criterion. The Scaffolded → ImplementedByAI branch chooses between the AiAdapter (with --ai) and a deterministic stub (without), giving reproducible scenarios in environments without an AI key. The self-loop on ImplementedByAI handles transient test failures with a bounded retry policy.
Alt: State diagram in mermaid stateDiagram-v2 showing the initial arrow into Declared, Declared to Scaffolded via ScaffoldEmitted, Scaffolded to ImplementedByAI via two alternative transitions (AiImplement with the --ai flag, or ImplementStub without), ImplementedByAI to TestPassing via TestRunPass, a self-loop on ImplementedByAI labelled TestRunFail with retry, ImplementedByAI to Failed via TestRunFail with no retry, TestPassing to Reviewed via Review with the approved predicate, Reviewed to Verified via the Verified event, Verified to a terminal arrow, plus Fail transitions from Declared, Scaffolded, TestPassing, and Reviewed to a Failed state which also terminates.
Running-example recap
Let's walk FEATURE-TRACE-EXPLORER-TUI across the three FSMs, today, 2026-04-14.
ProjectLifecycleFsm: the package-wide FSM is currently at Implementing. Not Published. @frenchexdev/requirements has drafted its Requirements (all 22, including REQ-DISCOVERABLE-TRACEABILITY and REQ-DOG-FOOD which the explorer Feature satisfies), derived its Features (including the explorer), scaffolded every AC's test, and is now in the long Implementing phase — N PerAcFsm instances are forked, the AI-assisted ones are iterating, the stub ones are parked waiting for human hands. The package is not releasable. compliance --strict currently reports 217 uncovered ACs. Verifying is still weeks or months away.
PerFeatureFsm for FEATURE-TRACE-EXPLORER-TUI: sits at HasScaffold. The Feature class file is on disk. All ten AC tests are scaffolded with @FeatureTest(FeatureTraceExplorerTuiFeature) and @Verifies('...') in place. But implementedCount === 0. None of the ten tests has a real body yet. The machine cannot advance until at least one AC implementation lands — at which point AcImplemented fires, the counter increments, and the machine stays at HasScaffold until implementedCount === 10.
Ten PerAcFsm instances under this Feature: all ten sit at Scaffolded. None has transitioned to ImplementedByAI yet. The explorer Feature carries enabled: false and Priority.Low, so the AI implementer is not scheduling it ahead of higher-priority work. When a future scenario plays the explorer's implementation stream — likely once the requirements feature new wizard and the compliance scanner are fully verified — the ten PerAcFsm instances will each transition Scaffolded → ImplementedByAI → TestPassing → Reviewed → Verified, firing ten AcImplemented events upward. The tenth event trips the count-equality guard on PerFeatureFsm.HasScaffold → AllAcsImplemented, and the Feature machine advances one state.
The whole trajectory — one Feature, ten ACs, three FSMs composed — is what the existing scenario record command will capture as the ACs land, and what a future scenario play against a fresh workspace will regenerate byte-for-byte.
Contrast with NEW-COMMAND. That Feature's PerFeatureFsm is Verified, terminal. All its PerAcFsm instances are Verified, terminal. The scanner colours it green with a crown in the trace-explorer. The ProjectLifecycleFsm still cannot reach Published because other Features remain unverified — but NEW-COMMAND's contribution to the release is settled. A future regression on NEW-COMMAND would not be a state change in its per-Feature FSM; it would be a replay failure when the scenario that originally built NEW-COMMAND is re-played against the regressed code.
That is what the three-FSM stack buys. Every Feature, every AC, every Requirement has a state, and that state is interrogable, colourable, replayable, and guarded against incoherent combinations. The machinery is small — three FSMs, seven to eight states each, three cross-FSM guards. The invariants it enforces are real.
Related reading
- Finite state machines, close up — the longer treatment of guards, effects, and the
@FiniteStateMachine/@State/@Transitionvocabulary; primary source for the typed-fsm primitives this chapter composes. - Chapter 14 — the scenarii stream as parallel deliverable — why Scenarii are a first-class deliverable alongside Requirements and Features; how
REQ-PARALLEL-DELIVERABLEframes the four-tier output. - Chapter 16 — cross-package adoption of @typed-fsm — the next chapter. How other
@frenchexdev/*packages consume the same primitives;@FiniteStateMachine({ features: [...] })across package boundaries. - CMF design series — orchestration — the C# CMF's Workflow DSL, which predates this TypeScript orchestration and informed the three-FSM decomposition. Same pattern, different language.
- Requirement vs Feature — the distinction this series' Chapter 00 named as the original gap; the three FSMs here realise the distinction in lifecycle form.