# SMCraft / Stateloom — State Machine Craft (Full Reference) > Design, validate, generate and run hierarchical state machines. One declarative format (SMDF) is read by an engine, a code generator, an MCP server, a terminal CLI, a socket.io hub and a browser canvas — so an agent, a person at a shell and a person at a canvas can edit the same document while it is open, and each sees the others' edits arrive. **Engine (npm):** `@miadi/stateloom-engine` 0.4.4 — renamed from `smcraft`, which is deprecated on npm — parser, validator, runtime, SMDF interpreter, TypeScript codegen **Engine (PyPI):** `miadi-stateloom-engine` 0.2.1 — the Python twin, Python codegen, CLI `smcg` **Protocol:** `@miadi/stateloom-protocol` 0.1.14 — also carries the ERD, sequence and system formats (types, validators, links between drawings, the scenario replay and reconcile, layouts) **Client:** `@miadi/stateloom-client` 0.1.3 — `emitView` / `on('view')` **Hub:** `@miadi/stateloom` 0.1.6 — bin `smcraft-bridge`; relays `view:show` **React:** `@miadi/stateloom-react` 0.1.6 **Canvas:** `@miadi/stateloom-canvas` 0.1.5 — ``, `` and ``, the design surfaces as mountable components **CLI:** `@miadi/stateloom-cli` 0.1.4 — bin `smcx` **MCP:** `@miadi/stateloom-mcp` 0.3.5 — bins `stateloom-mcp`, `smcraft-mcp`; 56 tools: 22 state-machine, 10 ERD, 14 sequence, 10 system **Skills:** `@miadi/stateloom-skills` 0.4.6 — bin `stateloom` **Designer:** `web/` — `"private": true`; its prebuilt standalone build ships as `@miadi/stateloom-web` 0.2.6, bin `stateloom-web` **Framework:** RISE v1.2 (Reverse-engineer → Intent-extract → Specify → Export) **RISE Specs:** 70–82 **Repository:** https://github.com/jgwill/smcraft **Published at:** https://docs.smcraft.jgwill.com --- ## 1. Architecture Overview Two pipelines share one format. The first turns a definition into code you ship. The second keeps a definition in agreement across every surface currently looking at it. ### Generation pipeline ``` .smdf.json definition (or .smdf.xml / .fsm) ↓ Parser (Spec 70) ↓ EnrichedModel (lookup maps: state_map, event_map, timer_map, feeders_map, parent_map) ↓ Validator (V001–V014) ↓ Code Generator (Spec 72) ↓ Python / TypeScript state machine classes ↓ Runtime Engine (Spec 71) executes them ``` ### Live pipeline ``` agent (MCP) terminal (smcx) canvas (web/) │ │ │ └────── PatchOp ─────┴──── PatchOp ────────┘ │ @miadi/stateloom (hub) in-memory doc + seq per room presence · chokidar external-edit differ │ room key = ABSOLUTE project-file path │ .smdf.json on disk ``` Mutations are durable-first everywhere: write the file, then emit the patch. A surface that cannot reach the hub still produces a correct document. ### Package Topology ``` smcraft/ ├── py/ # PyPI: miadi-stateloom-engine 0.2.1 │ └── stateloom/ # model.py, parser.py, runtime.py, codegen.py, cli.py (smcg) ├── ts/ # npm: @miadi/stateloom-engine 0.4.4 (was smcraft) │ └── src/ # model.ts, parser.ts, runtime.ts, codegen.ts, index.ts, │ # machine.ts — the SMDF interpreter (no codegen) ├── bridge-protocol/ # npm: @miadi/stateloom-protocol 0.1.8 │ └── src/ # definition, ops (PatchOp), apply, diff, seq, events (EV), │ # tree, autoLayout, edgeRoutes, edgeLabels, glyphs, notes, │ # exportName, env (envAlias), render/{mermaid,ascii}, │ # erd/ — the ERDF format: types, validate, links, layout, edit ├── bridge-client/ # npm: @miadi/stateloom-client 0.1.2 │ └── src/client.ts # createBridgeClient — join/patch/full/presence ├── bridge/ # npm: @miadi/stateloom 0.1.4 — bin smcraft-bridge │ └── src/ # hub.ts (startBridge), docio.ts, watcher.ts, bin.js ├── bridge-react/ # npm: @miadi/stateloom-react 0.1.6 │ └── src/ # session.ts, useSmcraftBridge.ts, autoLayout.ts, viewport.ts ├── bridge-canvas/ # npm: @miadi/stateloom-canvas 0.1.4 │ └── src/ # StateMachineCanvas.tsx, EntityRelationshipCanvas.tsx, │ # drill.ts, theme.ts + styles.css — every colour a --slc-* var ├── cli/ # npm: @miadi/stateloom-cli 0.1.3 — bin smcx │ └── src/ # index.ts, docio.ts, mutate.ts, commands/, render/ ├── mcp/ # npm: @miadi/stateloom-mcp 0.2.6 │ └── src/ # server.ts (17 state-machine tools, 1 resource, 1 prompt), │ # erd.ts (10 ERD tools), projectSwitch.ts ├── skills-cli/ # npm: @miadi/stateloom-skills 0.3.2 — bin stateloom ├── skills/ # the ten skill sources the CLI installs ├── web/ # source PRIVATE — the designer app (Next.js) ├── web-dist/ # npm: @miadi/stateloom-web 0.1.9 — web/'s prebuilt standalone build ├── docker/, Dockerfile # jgwill/stateloom — the whole loom behind one port ├── scripts/live-loop.sh # one absolute source of truth for the live loop ├── examples/ # Example SMDF and ERDF definitions ├── rispecs/ # RISE specifications 70–80 └── KINSHIP.md # Lineage and relationships ``` ### Design surfaces `smcg` (parse, validate, generate from the command line) · the Python / TypeScript API (programmatic parsing, validation, interpretation, generation) · the MCP server (agents designing conversationally) · `smcx` (drive the live loom from a terminal, render without a browser) · the web designer (drag-and-drop SVG canvas, live-synced with the others). All five read and write the same `.smdf.json`. --- ## 2. SMDF Schema Specification ### File Format Extension: `.smdf.json` (primary), `.smdf.xml`, `.fsm` (legacy XML) ### Top-Level Structure ```json { "settings": { "namespace": "Trading.Strategies", "name": "FDBBreakoutStrategy", "asynchronous": false, "objects": [ { "instance": "strategy", "class": "FDBBreakoutStrategyEntity" }, { "instance": "market", "class": "MarketDataProvider" } ], "context": { "class": "FDBBreakoutContext" } }, "events": [ { "name": "StrategyEvents", "feeder": "StrategyFeeder", "events": [ { "id": "Activate", "parameters": [{ "name": "strategyId", "type": "string" }] }, { "id": "BreakoutDetected", "parameters": [{ "name": "price", "type": "float" }] }, { "id": "FDBSignalFound" }, { "id": "OrderFilled" } ], "timers": [{ "id": "evMaxPuiTimeout", "name": "MaxPuiTimer" }] } ], "state": { "name": "Root", "states": [ { "name": "Created", "transitions": [{ "event": "Activate", "nextState": "Active_WaitingBreakout" }] }, { "name": "Active", "states": [ { "name": "Active_WaitingBreakout", "onEntry": { "actions": [{ "code": "market.start_price_monitoring(strategy.breakout_price)" }] }, "transitions": [{ "event": "BreakoutDetected", "nextState": "Active_WaitingSignal" }] }, { "name": "Active_WaitingSignal", "transitions": [{ "event": "FDBSignalFound", "nextState": "Active_WaitingEntering" }] }, { "name": "Active_WaitingEntering", "transitions": [{ "event": "OrderFilled", "nextState": "Completed" }] } ] }, { "name": "Completed", "kind": "final" }, { "name": "Faulted", "kind": "final" } ] } } ``` ### Settings Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `namespace` | string | ✓ | Code namespace for generated output | | `name` | string | | State machine name (defaults to filename) | | `asynchronous` | boolean | | If true, generates ContextAsync with event queue | | `objects` | ObjectRef[] | | Domain objects accessible in actions/conditions | | `context` | ContextConfig | | Override context class/instance naming | | `using` | string[] | | Additional imports for generated code | | `_source` | object | | Provenance. `pdeId` / `pdeFolder` let `generate_rispec` fold the originating PDE's intent, directions and ambiguities into the spec it writes. | ### ObjectRef, EventSourceDef, EventDef, TimerDef ```json { "instance": "strategy", "class": "BDBOStrategyEntity", "namespace": "Trading" } ``` Objects are stored on the generated Context class and reachable from action code as `context.strategy.method()`. An **EventSourceDef** groups related events and generates a Feeder class with a typed method per event: `name` (identifier), `feeder` (generated class name), `events[]`, `timers[]`. ```json { "id": "OrderFilled", "parameters": [{ "name": "tradeId", "type": "string" }, { "name": "fillPrice", "type": "float" }] } { "id": "evTimeout", "name": "Timeout" } ``` Parameter types: `string`, `int`, `float`, `double`, `bool`, `boolean`, `object`, or custom type names. Timers generate as events; in actions they appear as `timerStart: { "timer": "Timeout", "duration": "60000" }` and `timerStop: "Timeout"`. ### StateDef | Field | Type | Description | |-------|------|-------------| | `name` | string | Unique state identifier | | `kind` | "normal" \| "final" \| "history" | State classification (default: normal) | | `description` | string | Human-readable description | | `onEntry` | { actions: ActionDef[] } | Entry actions | | `onExit` | { actions: ActionDef[] } | Exit actions | | `transitions` | TransitionDef[] | Event-triggered transitions | | `states` | StateDef[] | Child states (makes this composite) | | `parallel` | ParallelDef | Orthogonal regions | **State classification (derived from structure, not declared):** leaf (`states == [] && parallel == null`), composite (`states.length > 0`), final (`kind == "final"` — no outgoing transitions, no children), history (`kind == "history"` — remembers the last active child), parallel (`parallel != null` — orthogonal regions), root (top of the tree). ### TransitionDef ```json { "event": "OrderFilled", "nextState": "Completed", "condition": "strategy.validate_mpr(entry_price, stop_price)", "actions": [{ "code": "strategy.record_fill(trade_id, fill_price)" }] } ``` - `event`: Required — references a defined EventDef.id - `nextState`: Optional — if omitted, this is an internal transition (no state change) - `condition`: Optional — guard expression; transition only fires if true - Multiple transitions on the same event create conditional branches (if/elif/else) ### ActionDef and ParallelDef An action takes one of three forms; a parallel block takes regions and an exit state: ```json { "code": "strategy.start_monitoring()" } { "timerStart": { "timer": "Timeout", "duration": "60000" } } { "timerStop": "Timeout" } { "nextState": "Completed", "states": [{ "name": "RegionA", "states": [] }, { "name": "RegionB", "states": [] }] } ``` A parallel block requires ≥2 regions, each with ≥1 child state. `nextState` is the exit state taken when all regions complete. ### Validation Rules (V001–V014) | Rule | Description | |------|-------------| | V001 | Exactly one root state must be defined | | V002 | All state names must be unique | | V003 | All event IDs must be unique | | V004 | Timer IDs must not collide with event IDs | | V005 | Transition events must reference defined events | | V006 | Transition nextState must reference defined states | | V007 | Final states must have no outgoing transitions | | V008 | Final states must have no children | | V009 | Composite states must have at least one non-final initial child | | V010 | Parallel states must have ≥2 regions, each with ≥1 child | | V011 | Parallel region transitions must target within region or parent exit state | | V012 | Composite states must have at least one child | | V013 | At least one event source must be defined | | V014 | Timer references in actions must reference defined timers | V002 is load-bearing beyond validation: because state names are unique, `name` is the stable key the bridge's PatchOp vocabulary uses to address a state. **Which implementation runs which rule.** The format specifies all fourteen; the three validators in this repo do not agree, and a caller that assumes they do will misread a clean result as a clean chart: | Validator | Rules | Note | |---|---|---| | Python engine — `stateloom.parser.validate` | V001–V014 | the complete set; `smcg` is the way to get all of them | | TypeScript engine — `parser.validate` | V001, V002, V003, V005, V006, V007, V013 | **V009, V011, V012 are not implemented** — an empty composite passes in JS and fails in Python | | MCP `validate_definition` | its own five reference/uniqueness checks | **rule IDs collide with different meanings**: its V001 is "no events defined", V004 an unknown transition target, V005 "root has no child state" | Read the message, never the number, unless you know which of the three answered. --- ## 2b. ERDF — the Entity-Relationship Definition Format The sibling of SMDF. A `.smdf.json` describes one behaviour; a `.erdf.json` describes the data every behaviour acts on, once, for all of them. It is a design document, written during conceptualization. Full spec: [80 — ERDF Format](./rispecs/80-erdf-format.spec.md). Implementation: `bridge-protocol/src/erd/` (TypeScript only; there is no Python twin yet). ```json { "settings": { "namespace": "Examples.Library", "name": "LibraryLending", "description": "…", "notes": "…" }, "entities": [ { "name": "Loan", "description": "…", "weak": false, "notes": "…", "attributes": [ { "name": "loan_id", "type": "int", "key": "pk" }, { "name": "member_id", "type": "int", "references": "Member.member_id" }, { "name": "status", "type": "string", "stateOf": "LoanLifecycle" } ] } ], "relationships": [ { "from": "Member", "to": "Loan", "cardinality": "1:N", "label": "takes out" } ] } ``` - **Attribute** — `name`, `type` (free string), `key` (`pk` | `uk`), `references` (`"Entity"` or `"Entity.attribute"` — this is what makes a foreign key, so one attribute can be both pk and fk), `nullable`, `stateOf` (the `settings.name` of the machine whose state it stores; a list when one column serves several machines), `description`. - **Relationship** — `from`, `to`, `cardinality` (`1:1` | `1:N` | `N:1` | `N:M`, read left to right), `label` (the verb). Addressed by index, as transitions are. - **Entity** — `weak: true` for an entity that exists only through another. **Validation** — `validateErd(def)`: E001 entity names unique and non-empty · E002 attribute names unique within an entity · E003 relationship ends exist · E004 cardinality is a known value · E005 `references` resolves. **The link to SMDF is by name only.** `settings.objects[].class` ↔ entity name; a guard's `.` ↔ an attribute of that entity; `settings.name` ↔ an attribute's `stateOf`. `checkLinks(smdf, erdf)` reports: L001 an object a guard reads fields of has a class that is no entity (an object no guard reads is treated as a service and left alone) · L002 a guard reads a field the entity lacks · L003 an attribute stores this machine's state but its entity is not one of the machine's objects. `checkStateOf(erdf, machineNames)` adds L004: `stateOf` names no known machine. Guards are free text: only the `.` shape is read, and method calls are skipped. **Notation is a view, never data.** `ErdNotation = "crowsfoot" | "chen"`. Crow's foot lists attributes as rows inside the entity box; Chen draws a rectangle (double when `weak`), an oval per attribute with the primary key underlined, a diamond per relationship, and `1`/`N`/`M` beside the line. The choice is the viewer's and is never written to the file, so every reader — tools, link check, a code generator — reads the same document. `erdAutoLayout(def, { notation })` lays out for either; `renderMermaidEr(def)` gives a Mermaid `erDiagram` (crow's foot; Mermaid has no Chen). **Notes** — both formats carry `settings.notes` and `notes` on a shape (a state, an entity): what a person or an agent wrote down while discussing the diagram, for whoever opens it next. Engines and codegen ignore them. `collectNotes(def)` reads them all, the diagram's own first. A note is a string; a list of paragraphs written by hand is read as text (`noteText`), and every tool normalizes notes when it loads a document (`normalizeNotes`). In the live diff an erased note travels as `""` and `applyPatchOps` removes the key. **Transport** — an ERD has no PatchOp vocabulary; it always travels whole (`def:full`). The hub never validates what a room holds, so an ERDF rides the same hub, room keyed by path. ## 2c. SQDF — the Sequence Definition Format The third sibling: a usage scenario, told as who sends what to whom, in order. Full spec: [81 — SQDF Format](./rispecs/81-sqdf-format.spec.md). Implementation: `bridge-protocol/src/sequence/`. ```json { "settings": { "namespace": "jgt.trading", "name": "WaveCountToEntry", "description": "…", "notes": "…" }, "participants": [ { "name": "Guillaume", "actor": "Guillaume" }, { "name": "Analysis", "service": ":8085", "holds": ["WaveCount"] }, { "name": "Market", "object": "market" } ], "messages": [ { "from": "Guillaume", "to": "Blueprint", "label": "authorizes", "event": "Authorize", "carries": "CampaignMandate" }, { "from": "Blueprint", "to": "Strategy", "label": "creates BOS", "event": "StrategyCreated", "state": "StrategicEntry" } ], "fragments": [ { "kind": "alt", "label": "the market settles the count first", "after": 11, "messages": [ … ] } ] } ``` - **Participant** — `name`, and what it is: `actor` (an actor of the system), `service` (free text), `object` (a machine's `settings.objects[].instance`), `holds` (entities). - **Message** — `from`, `to`, `label`; optionally `event` (fired in every member machine that defines it, or only in `machine`), `carries` (an entity), `state` (the state the machine is in afterwards — checked by the replay, and what lets reconcile add a missing transition), `optional`, `reply`. A message may do none of these: some only move data. - **Fragment** — `kind` `alt` (instead of the main messages after `after`) | `opt` (may happen, then the main messages continue) | `loop` (repeats, then they continue), `label`, `after` (the main message it branches after, `0` = before the first), `messages`. - **Addresses** — `"9"` is main message 9; `"f1.2"` the second message of the first fragment. Tools, the replay, the canvas and a `focus` all use them. **Validation** — `validateSequence(def)`: S001 `settings.name` · S002 participant names unique and non-empty · S003 each message's ends are participants · S004 each message has a label · S005 fragment kind and `after` in range · S006 a fragment holds a message. **Edits** — `addParticipant`, `updateParticipant`, `removeParticipant` (takes its messages), `addMessage`, `updateMessage`, `removeMessage`, `moveMessage`, `addFragment`, `updateFragment`, `removeFragment`, `addFragmentMessage`, `updateSqdSettings`: pure, 0-based, and each keeps every fragment on the message it branches after. **Rendering** — `renderMermaidSequence(def)`: actors as `actor`, the event after each label, an optional message in its own `opt`, an `alt` at its branch point with `else as written` holding the path it replaces. `sequenceLayout(def)` is the geometry every canvas shares. **Transport** — whole (`def:full`), as an ERD. ## 2d. SYSDF — the drawings as one system A `.sysdf.json` names the members of one system (ERDs, machines, sequences — never another system; paths relative to the system file, the only document that holds paths) and the actors its scenarios share. Full spec: [82 — SYSDF](./rispecs/82-sysdf-system.spec.md). Implementation: `bridge-protocol/src/system/`. ```json { "settings": { "namespace": "jgt.trading", "name": "ElliottWaveCount", "reconcile": "propose" }, "members": [ { "path": "elliott_wave_count.erdf.json" }, { "path": "elliott_wave_count.smdf.json" }, { "path": "wave_count_to_entry.sqdf.json" } ], "actors": [ { "name": "Guillaume", "kind": "person" }, { "name": "Agent", "kind": "agent" } ] } ``` **Checks** — `checkSystem(system, members)`: Y001 name and namespace · Y002 member paths present, unique, of a member kind · Y003 a member reads, and holds what its extension says · Y004 actors unique and of a known kind · Y005 no two machines share a name · each ERD's E rules and each sequence's S rules · L001–L004 for every machine against the entities of every ERD · L005 a message's event is an event of a member machine · L006 a participant's actor, object and held entities exist · L007 a message's carried entity exists · L008 (warning) each scenario path is a walk the machines accept, and a message's `state` names a state some machine has · L009 (warning) a state that is not final has a way out. Only the first stop of each machine on each path is reported; later stops are counted. **The replay** — `replayScenario(seq, machines)` follows `ts/src/machine.ts`: start in the first enterable leaf; look an event up on the leaf, then each ancestor; no `nextState` stays; a composite enters its first non-history child down to a leaf; a `final` state ends the machine. Guards are not evaluated, so the walk keeps the set of states a guard could lead to; a message is the scenario's claim that its event is handled, so refusing states are dropped when others accept, and `state` narrows the set. Paths: `main`, `main without optional messages`, and one per fragment. Steps: `moved`, `stayed`, `refused`, `unknown-event`, `ended`, `state-mismatch`, `data`. `formatReplay(report)` prints it. **Reconcile** — `reconcileScenario(seq, machines)` proposes, as PatchOps, what the scenario says: an event no machine defines (added to the sender's source, created when missing) and a refused transition when the message names the `state` that follows (adding the state under the root when missing). `reconcileSystem(system, members)` does it for every scenario and adds entities carried or held (to the ERD, with a note saying which message asked), actors (to the system), and a participant's object when one machine and one held entity say which. The rest is returned as `unresolved`, in words. Nothing is removed or rewritten. `summarizeReconcile(result, mode)` is the one line a tool shows. **The mode** — `settings.reconcile`: `propose` (default: list the changes, wait for `reconcile_scenario apply:true`) or `auto` (every sequence edit applies them at once and answers in one line). `reconcileModeOf(def, override)`; `STATELOOM_RECONCILE` overrides per session. **Links as data** — `systemLinks(members)` gives the same crossings as `{from, to, rule, text, back}` between `{member, kind, name}` references (`entity`, `attribute`, `machine`, `object`, `event`, `state`, `participant`, `message`), plus `message → state` from the replay's main path. `linksOf(links, member, ":")` reads them from one element's side. **The view** — `ViewEnvelope { docId, member?, focus?, origin, note? }` over `view:show`: the hub relays it to the system file's room and keeps nothing; the sender need not have joined; the token is checked as on join. `emitView` and `on('view')` in `@miadi/stateloom-client`. Worked example: `examples/wave-count/` — the Episode 140 wave count: 0 errors, and two L008 warnings that are known and intended. --- ## 3. Python Package Deep Dive (`miadi-stateloom-engine` 0.2.1, PyPI) ### Installation ```bash pip install miadi-stateloom-engine # or from source: cd py && pip install -e . ``` ### Module: `stateloom.model` (Spec 70) Dataclass-based SMDF representation. ```python from stateloom import ( StateMachineDefinition, # Root: settings + event_sources + root_state SettingsModel, EventSourceDef, EventDef, TimerDef, ParameterDef, StateDef, TransitionDef, ActionDef, ParallelDef, ObjectRef, ContextConfig, StateKindType, # Enum: NORMAL, FINAL, HISTORY ) # StateDef carries derived predicates: state.is_leaf; state.is_composite; state.is_final; state.is_history; state.is_parallel definition = StateMachineDefinition(settings=..., event_sources=[...], root_state=...) json_str = definition.to_json(indent=2) ``` ### Module: `stateloom.parser` (Spec 70) ```python from stateloom.parser import StateMachineParser, EnrichedModel, ValidationError parser = StateMachineParser() model: EnrichedModel = parser.parse_file("input.smdf.json") # auto-detects JSON/XML/FSM definition = parser.parse_json(json_string) definition = parser.parse_xml(xml_string) model = parser.enrich(definition) errors: list[ValidationError] = parser.validate(model) for err in errors: print(f"[{err.rule_id}] {err.message} ({err.element})") ``` **EnrichedModel fields** — `definition`, `state_map` (name → state), `event_map` (id → event), `timer_map` (name → timer), `feeders_map` (feeder → events), `parent_map` (state → parent or None), `all_states` (tree order), `leaf_states`, `composite_states`. **XML support:** parses the StateMachineDotNet-v1 namespace (`http://www.stateforge.com/StateMachineDotNet-v1`), namespaced and non-namespaced alike, including the legacy `.fsm` extension. ### Module: `stateloom.runtime` (Spec 71) ```python from stateloom.runtime import ( StateKind, # LEAF=0, COMPOSITE=1, ROOT=2, FINAL=3, PARALLEL=4, HISTORY=5 State, Context, ContextAsync, ContextBase, TransitionHelper, IObserver, ObserverNull, ObserverConsole, ObserverLogger, ) state = State(name="Active", kind=StateKind.COMPOSITE, parent=root_state) # Generated subclasses override on_entry(context) / on_exit(context). ctx = Context(name="MyContext") ctx.set_observer(ObserverConsole.instance()) # or ObserverLogger("mylogger") ctx.register_end_handler(lambda c: print(f"{c.name} completed")) ctx.enter_initial_state() # generated code overrides this ctx.state_current # active leaf state ctx.state_previous # last state before transition ctx.state_history # saved state for history support ctx.start_timer("Timeout", 60000, lambda: ctx.on_timeout()) ctx.stop_timer("Timeout"); ctx.stop_all_timers() data = ctx.serialize() # {"state": "Active_WaitingSignal", "history": "..."} ctx.deserialize(data) # restores state_current via set_state() # Async: events are queued and processed on a background thread. actx = ContextAsync(name="AsyncContext", max_events=1024) actx.schedule_event(handler_fn, *args) ``` **TransitionHelper** — the three-step move generated code performs: ```python lca = TransitionHelper.find_common_ancestor(state_a, state_b) # 1. Exit chain: walk from the current state up to the LCA, calling on_exit TransitionHelper.process_transition_begin(context, state_prev, state_next, "EventName") # 2. Set the new current state (generated code does this) context.state_current = state_next # 3. Entry chain: walk from the LCA down to the target, calling on_entry TransitionHelper.process_transition_end(context, state_prev, state_next) # Calls context._on_end() automatically when state_next is FINAL ``` **IObserver protocol** — `on_entry(context_name, state_name)`, `on_exit(…)`, `on_transition_begin(context_name, state_prev, state_next, transition_name)`, `on_transition_end(…)`, `on_timer_start(context_name, timer_name, duration)`, `on_timer_stop(context_name, timer_name)`. ### Module: `stateloom.codegen` (Spec 72) ```python from stateloom.codegen import PythonCodeGenerator, generate_python # High-level: code = generate_python("input.smdf.json", "output/machine_fsm.py") # Low-level: parser = StateMachineParser() model = parser.parse_file("input.smdf.json") code = PythonCodeGenerator(model).generate() ``` Generated structure: 1. `{Name}StateEnum(IntEnum)` — state enumeration 2. `State{Name}(State)` — base state class with virtual `on_{event}()` methods 3. `State{LeafName}(State{Name})` — one class per leaf state 4. `{Name}Context(Context)` — context class with state fields, object refs, `enter_initial_state()`, `set_state()`, `on_{event}()` 5. `{Feeder}` — feeder class with typed event methods Naming: PascalCase → snake_case for Python identifiers (`OrderCreated` → `on_order_created`). Type mapping: string→str, int→int, float→float, bool→bool, object→Any. ### Module: `stateloom.cli` (Spec 72) ```bash smcg [--output|-o ] [--language|-l python] [--name|-n ] [--validate-only] [--verbose|-v] smcg examples/bdbo_strategy.smdf.json -o output/ -v smcg examples/fdb_breakout_strategy.smdf.json --validate-only smcg input.smdf.json -l python -n MyMachine -o generated/ ``` Output: `{output_dir}/{snake_case_name}_fsm.py` --- ## 4. TypeScript Engine Deep Dive (`@miadi/stateloom-engine` 0.4.4, npm) ### Installation ```bash npm install @miadi/stateloom-engine # or from source: cd ts && npm install && npm run build ``` ### Subpath Exports ```typescript import { ... } from "@miadi/stateloom-engine"; // Barrel: all exports import { ... } from "@miadi/stateloom-engine/runtime"; // Runtime only import { ... } from "@miadi/stateloom-engine/machine"; // SMDF interpreter import { ... } from "@miadi/stateloom-engine/parser"; // Parser only import { ... } from "@miadi/stateloom-engine/codegen"; // Code generator only ``` ### Model Types (`@miadi/stateloom-engine/model.ts`) The §2 schema, as interfaces: ```typescript interface StateMachineDefinition { settings: SettingsModel; events: EventSourceDef[]; state: StateDef } interface SettingsModel { namespace: string; name?: string; asynchronous: boolean; objects?: ObjectRef[]; context?: ContextConfig; using?: string[] } interface StateDef { name: string; kind?: "normal" | "final" | "history"; description?: string; onEntry?: { actions: ActionDef[] }; onExit?: { actions: ActionDef[] }; transitions?: TransitionDef[]; states?: StateDef[]; parallel?: ParallelDef } interface TransitionDef { event: string; nextState?: string; condition?: string; description?: string; actions?: ActionDef[] } interface ActionDef { code?: string; timerStart?: { timer: string; duration: string }; timerStop?: string } interface EventDef { id: string; name?: string; description?: string; parameters?: ParameterDef[] } ``` ### Parser (`@miadi/stateloom-engine/parser`) ```typescript import { parseJson, parseFile, enrich, validate, type EnrichedModel, type ValidationError } from "@miadi/stateloom-engine/parser"; const definition = parseJson(jsonString); const model: EnrichedModel = enrich(definition); const errors: ValidationError[] = validate(model); model.stateMap // Map model.eventMap // Map model.timerMap // Map model.feedersMap // Map model.parentMap // Map model.allStates // StateDef[] model.leafStates // StateDef[] model.compositeStates // StateDef[] ``` ### Runtime (`@miadi/stateloom-engine/runtime`) ```typescript import { State, StateKind, Context, ContextAsync, ContextBase, TransitionHelper, ObserverNull, ObserverConsole, type IObserver, type EndHandler } from "@miadi/stateloom-engine/runtime"; // StateKind: LEAF, COMPOSITE, ROOT, FINAL, PARALLEL, HISTORY const ctx = new Context("MyContext"); ctx.setObserver(ObserverConsole.instance()); ctx.registerEndHandler((c) => console.log(`${c.name} completed`)); // Same LCA algorithm as Python: TransitionHelper.processTransitionBegin(context, statePrev, stateNext, "EventName"); TransitionHelper.processTransitionEnd(context, statePrev, stateNext); // ContextAsync processes via queueMicrotask: const asyncCtx = new ContextAsync("AsyncCtx", 1024); asyncCtx.scheduleEvent(handler, ...args); ctx.startTimer("Timeout", 60000, () => ctx.onTimeout()); ctx.stopTimer("Timeout"); ctx.stopAllTimers(); const data = ctx.serialize(); ctx.deserialize(data); ``` ### Interpreter — `Machine` (`@miadi/stateloom-engine/machine`) `Machine` drives a definition directly. No code generation, no build step — the drive-path contract external tools reach for when they hold an SMDF and want it to run. ```typescript import { Machine, MachineDefinitionError } from "@miadi/stateloom-engine/machine"; const machine = new Machine(definition, { name: "OrderFlow", observer: ObserverConsole.instance(), context: { canRetry: true }, // guard lookup table // guard: (condition, payload, context) => boolean // validate: true (default) }); machine.state; // current leaf state name machine.done; // true once a final state is reached machine.visited; // leaf state names in visit order machine.warnings; // non-fatal ValidationError[] machine.availableEvents(); // event ids the current state can handle machine.can("go"); // would send() be handled? const r = machine.send("go", { orderId: "x" }); // r: { handled, changed, from, to, event, error? } machine.setState("Active"); // restore a persisted position machine.stop(); // clear timers ``` Semantics, locked by `ts/src/tests/machine.test.ts`: - Construction enters the initial state — first-child descent from root, skipping history pseudo-states. - `send()` resolves the event from the current leaf upward through its ancestors; the **deepest** state declaring a matching transition wins. - A transition without `nextState` is internal: `handled: true`, `changed: false`. - A transition targeting the current state also resolves as internal — no exit / re-entry. - A transition targeting a composite state descends to that composite's initial leaf. - Guarded transitions are skipped unless the guard passes. The default guard is a truthy lookup of the condition string in `options.context`, and fails closed when no context is given. - Reaching a `kind: "final"` state ends the machine; later sends return `handled: false`. - `code` actions are NOT executed — they are strings meant for generated code. `timerStart` / `timerStop` run when the duration is numeric, and a fired timer sends its timer event id. Validation on construction: V001 (no root), V002 (duplicate state) and V006 (unknown `nextState`) throw `MachineDefinitionError`, because a definition carrying any of them cannot be interpreted at all. Every other finding lands in `machine.warnings`. ### Code Generator (`@miadi/stateloom-engine/codegen`) ```typescript import { TypeScriptCodeGenerator } from "@miadi/stateloom-engine/codegen"; import { parseJson, enrich } from "@miadi/stateloom-engine/parser"; const model = enrich(parseJson(jsonString)); const code: string = new TypeScriptCodeGenerator(model).generate(); ``` Generates the TypeScript equivalent of the Python output: state enum, base state class, leaf state classes, context class, feeder classes. --- ## 5. The Real-Time Bridge Spec 77 is the contract; this section is what shipped. ### 5.1 `@miadi/stateloom-protocol` 0.1.14 Zero runtime dependencies. Everything else in the loom depends on it, and it depends on nothing — which is why the same layout, the same edge routing and the same file names appear on every surface without any of them importing each other. **PatchOp — the atomic vocabulary.** Op names mirror the MCP tools and the web store's granular actions. States are addressed by `name` (V002 makes that safe); events by `id`; parameters, transitions, actions and event sources positionally by index. ```typescript type PatchOp = | { op: 'settings.update'; patch: Partial } | { op: 'state.add'; parent: string | null; state: StateDef; index?: number } | { op: 'state.update'; name: string; patch: Partial> } | { op: 'state.remove'; name: string } | { op: 'state.nest'; child: string; newParent: string } | { op: 'eventSource.add'; source: EventSourceDef; index?: number } | { op: 'eventSource.update'; index: number; patch: Partial } | { op: 'eventSource.remove'; index: number } | { op: 'event.add'; sourceIndex: number; event: EventDef; index?: number } | { op: 'event.update'; id: string; patch: Partial } | { op: 'event.remove'; id: string } | { op: 'parameter.add'; eventId: string; parameter: ParameterDef; index?: number } | { op: 'parameter.update'; eventId: string; index: number; patch: Partial } | { op: 'parameter.remove'; eventId: string; index: number } | { op: 'transition.add'; state: string; transition: TransitionDef; index?: number } | { op: 'transition.update'; state: string; index: number; patch: Partial } | { op: 'transition.remove'; state: string; index: number } | { op: 'action.add'; state: string; hook: 'onEntry' | 'onExit'; action: ActionDef; index?: number } | { op: 'action.update'; state: string; hook: 'onEntry' | 'onExit'; index: number; action: ActionDef } | { op: 'action.remove'; state: string; hook: 'onEntry' | 'onExit'; index: number } | { op: 'runtime.enter'; state: string; from?: string; eventId?: string } | { op: 'runtime.exit'; state: string }; ``` The two `runtime.*` ops are presentational — which state is lit up right now. `applyPatchOps` ignores them; they never mutate the definition. **Wire names (`EV`)** — one source of truth shared by hub, CLI, web and agent clients. Inbound and outbound names deliberately overlap on the def channel so a peer can relay without renaming: ```typescript export const EV = { // client → hub JOIN: 'bridge:join', LEAVE: 'bridge:leave', PATCH_IN: 'def:patch', FULL_IN: 'def:full', REQUEST: 'def:request', PRESENCE_IN: 'presence:update', // hub → client WELCOME: 'bridge:welcome', PATCH_OUT: 'def:patch', FULL_OUT: 'def:full', ACK: 'def:ack', PRESENCE_JOIN: 'presence:join', PRESENCE_LEAVE: 'presence:leave', PRESENCE_LIST: 'presence:list', PRESENCE_UPDATE: 'presence:update', ERROR: 'bridge:error', } as const; ``` **Envelopes:** `PatchEnvelope { docId, seq, baseSeq?, ops, origin, mtime? }`, `FullEnvelope { docId, seq, def, origin, mtime? }`, `DocSnapshot { docId, def, seq, mtime }`, `Presence { clientId, role, name?, color?, joinedAt }` with `role: 'agent' | 'cli' | 'web' | 'runtime'`. `colorFor(clientId)` derives a stable badge color from the id, so the same peer is the same color on every surface. **Pure pairs:** `diffDefinitions(a, b) → PatchOp[]` and `applyPatchOps(def, ops) → def`. Both are pure, which is what lets the hub compute a patch for an edit that happened in an editor it never saw. **Geometry:** `autoLayout` (deterministic box placement, `AUTO_LAYOUT_DEFAULTS`), `routeEdges` / `edgeCurve` / `selfLoopCurve` / `facingSides` / `portAt` / `bezier` (port assignment so two edges between the same pair get their own doors), `placeLabels` / `chipSize` / `glyphAt` / `guardText` (label chips settled so none prints through another), `eventGlyph` / `ALL_GLYPHS`. **Render:** `renderMermaid(def)` and `renderAscii(def)` — text pictures with no dependency on a browser or a rasterizer. **Naming:** `diagramFileName({ doc, machine, format, at })`, `timeStamp(at)`, `episodeOf(doc)`. See §8. **Environment:** `envAlias(suffix, env?)` reads `STATELOOM_${suffix}` first, falls back to `SMCRAFT_${suffix}`. `NEXT_PUBLIC_*` client reads cannot use it — Next.js inlines only literal accesses — so those keep an explicit `??` chain. **ERD (Spec 80):** the whole second document type lives here too — `erd/definition` (types), `validateErd` (E001–E005), `checkLinks` / `checkStateOf` / `guardFields` (L001–L004), `erdAutoLayout` / `erdEntitySize` / `ERD_BOX`, the Chen geometry (`ChenGeometry`, `ChenOval`), `renderMermaidEr`, and the pure edits `addEntity` … `removeRelationship` that the MCP tools and the ERD workspace both call. Same rule as the machines: the package computes, nobody else duplicates it. **Notes:** `collectNotes(def)` → `NoteEntry[]` for either document type, the diagram's own note first. Notes live in the document (`settings.notes`, `notes` on a shape) and are ignored by every engine, validator and generator — they are for the next person to open the board. ### 5.2 `@miadi/stateloom-client` 0.1.3 ```typescript import { createBridgeClient, type BridgeClient, type BridgeStatus } from "@miadi/stateloom-client"; ``` A framework-agnostic socket.io-client wrapper: join a room, send patches, send a full definition, receive both, track presence, and auto-resync when a sequence gap says this client missed something. No React, no DOM. ### 5.3 `@miadi/stateloom` 0.1.6 — the hub ```bash npx -y @miadi/stateloom # bin: smcraft-bridge smcraft-bridge --host 127.0.0.1 --port 4599 ``` ```typescript import { startBridge, normalizeDocId, watchRoom, DEDUP_RING_SIZE } from "@miadi/stateloom"; const handle = await startBridge({ host, port, file }); ``` What it holds: - **One in-memory doc + seq per room.** Rooms are keyed by the **absolute project-file path**. This is why `set_project_file` moves the disk target and the bridge room together — they are the same identity. - **Presence** — who is in the room, with role and color. - **An external-edit differ.** chokidar watches the file; when someone edits the `.smdf.json` in a text editor, the hub diffs old against new and broadcasts the resulting `PatchOp[]`, so a hand edit arrives on the canvas as an ordinary patch. - **mtime/hash dedup** over a ring of recent writes, so the hub does not echo back a change it just caused. **The hub never writes disk.** Durability belongs to whoever made the edit. This keeps a crashed hub from being able to corrupt a document. ### 5.4 `@miadi/stateloom-react` 0.1.6 Peer dependency: `react ^19`. ```typescript import { createBridgeSession, useSmcraftBridge, autoLayout, AUTO_LAYOUT_DEFAULTS, routeEdges, edgeCurve, selfLoopCurve, placeLabels, chipSize, eventGlyph, IDENTITY_VIEWPORT, VIEWPORT_LIMITS, fitToBoxes, panBy, zoomAt, zoomTo, screenToWorld, worldToScreen, viewportTransform, clampScale, normalizeViewport, } from "@miadi/stateloom-react"; ``` Two layers on purpose. `createBridgeSession` is the whole session with no React in it — unit-testable, no renderer. `useSmcraftBridge` wraps that core through `useSyncExternalStore`, which is what makes a live document a legitimate external store rather than a `useEffect` racing itself. The drawing helpers are re-exported here rather than re-implemented: the designer canvas reaches for a curve or a label position at this address and should not have to know which package underneath the geometry lives in. `viewport.ts` carries the pan/zoom math — `Viewport`, `Box`, `Point`, `ScaleLimits`, and the transforms between screen and world space. ### 5.5 `@miadi/stateloom-cli` 0.1.4 — `smcx` ```bash npm i -g @miadi/stateloom-cli ``` Global options: `--bridge ` (default `envAlias("BRIDGE_URL")`), `--doc ` (default `envAlias("PROJECT_FILE")`), `--name