Appearance
fsm v1 plan
Context
- A state is stored as an integer code (key = value). Event transitions are idempotent and deterministic. The same rules apply to six targets: go, npm (js+d.ts), php, php-ext, python and rust.
- The output is generated files per language. There is no parsing at runtime, and the generated code has no runtime dependency.
- Definitions have three first-class stores: JSON, Mermaid
stateDiagram-v2and DB (orm). One compiler core owns the rules: validation, lock and generation. - The model is kept simple enough that all three media hold it losslessly with their official syntax only. If a medium cannot hold part of the model, that is a sign the model is too complex.
- Tools: the GUI uses hyper (Node server), CRUD screens use crudui, and DB access uses orm.
- The repository started under its old name
status; S-01 committed this plan with the kit adoption, and S-18 renamed the library, its packages, commands and files to fsm (R12). It adopts kit. - The six language packages have identical folder structure. Only language-inherent differences are allowed, and they are declared in
config/layout.yml, which kit'smake layout-check(alint) checks (S-44). - The language packages are not published libraries. They are consumer-side test beds.
- Fixed decisions
- Value model: enum only, no bit flags.
- Transitions: an explicit graph.
- Runtime: generated code only.
- Principle: standards are enforced by tools. Layout, feature↔test correspondence and parity between languages are judged by check tools that have RED tests.
0. Scope (the only basis for audits and implementation)
Requirements R1–R12 (this list alone is the v1 scope)
| id | Requirement | Source |
|---|---|---|
| R1 | A state is an enum with an integer code (key = value). A code never changes and is never reused | initial request |
| R2 | Event transitions, including previous/next, are idempotent and deterministic | initial request |
| R3 | Generated files for six targets (go, npm js+d.ts, php, php-ext, python, rust), with no runtime parsing and no runtime dependency | initial request |
| R4 | Three definition stores (JSON, Mermaid, DB) and one rule set | user instruction |
| R5 | State management GUI: hyper + crudui + Mermaid display | user instruction |
| R6 | DB access through orm, for the definition DB store and the consumer persistence example | user instruction |
| R7 | Adopt kit. Language packages share one structure; only language-inherent differences are allowed | user instruction |
| R8 | Standards are enforced by tools (layout, feature↔test, language parity). Development order is RED→GREEN | user instruction |
| R9 | Test execution rules: per-case progress and timeout, 10-minute ceiling, owner checks, full suite once | user instruction |
| R10 | Checklist, commit and document discipline (section A) | user instruction |
| R11 | Documentation: the root README and every package README (English and Korean) explain the library completely for its users, and a documentation site is published on GitHub Pages | user instruction |
| R12 | The library is named fsm: repository polyspec/fsm, and every package, command, file and generated module carries the name | user instruction |
Out of v1 scope (not subject to audit findings)
- The not-adopted list in B-0
- Concurrency between several editors (the GUI has one editor)
- Package publishing and release (the language packages are test beds)
- Differences between DB dialects (owned by orm)
- Performance tuning, i18n, authentication and authorization
Audit rules (convergence condition)
- Each finding names the R-id it violates or the plan location it contradicts. A finding with neither is rejected as out of scope.
- Only three kinds of finding are core:
- An R violation.
- An internal contradiction in the plan.
- An implementation shown to be impossible against real code or tools.
- Everything else is minor: wording, a preferred alternative design, features out of scope. Minor findings do not block the plan and are handled when the item is implemented.
- The plan is fixed when there are zero core findings. Changing the scope itself needs a user decision.
- Audits during implementation follow the same rules, limited to the deliverables of the checklist item and its RED/GREEN criteria.
A. Development discipline (written once in AGENTS.md; never in the checklist or CHANGELOG)
- Basis of judgement
- The basis of every decision is "what is right". "The existing principle says so" is not a reason.
- A standard can be corrected, but only with evidence. Lowering a standard because it is hard to reach during testing is forbidden.
- When a judgement is hard or new reasoning appears, verify it adversarially with agents. Ask the agents for concrete fixes too, and repeat until there is no disagreement.
- Record each decision in the related document.
- kit rules
- English is canonical. A
.ko.mdfile carriessource-sha256, and technical terms stay in English. - The copies of kit (
kit.copiesof@polyspec/kit) are never edited. The same tree gives the same result. - Changes to shared tools are made in kit and run through the kit checklist (K items).
- English is canonical. A
- Development cycle: reproduce with RED → implement → verify with GREEN. Each item states an observable criterion.
- Fixtures of the root tool tests (check-name, check-readmes, gen-check) are built by the tests in temporary directories, never committed, so no kit gate reads them.
- Test location
- The base test of a feature lives in the package that owns it.
- compiler →
packages/compiler/tests - DB store →
packages/db/tests - GUI →
packages/gui/tests
- compiler →
- Language packages hold only consumer-side tests.
- The root holds only the tests of the root tools (C-1) and their helper
tests/support/.
- The base test of a feature lives in the package that owns it.
- Test execution
- Every test runs through kit
run-tests.mjs. That runner enforces per-case progress and elapsed-time output and a per-case timeout. There is no overall timeout. - One vector case = one language test.
- compiler-test, db-test, gui-test and every language test write
.results/<target>.jsonthroughrun-tests --results. run-tests gives every case its own timeout. The target is seconds. - Parallel runs happen only through
make -j6inpackages-test. - While an item is in development, only the owner checks of the owner-checks mapping (section D) run.
- The full suite runs once per tree with
full-run.mjsafter the last item is committed. It is not a checklist item.
- Every test runs through kit
- No hidden errors: no fallback, no silent conversion or drop, no null return. No backward-compatibility layer.
- Checklist
- Only
docs/plans/execution-checklist.md(.ko). The format is the kit table (| id | item | RED → GREEN | state |). - States are
[ ] [~] [o] [!] [-]. An[o]item is never reopened; a sub-id is created instead.
- Only
- Commits
- Format:
type(scope): Subject (#S-xx), in English. The subject is at most 50 characters, starts with a capital, has no period and is imperative. The body wraps at 72 characters and states What/Why. - When an item becomes
[o], the root CHANGELOG and the checklist are updated and committed.
- Format:
- Enforcement
- kit tools
commits-check+ commit-msg hook: subject format, length, body widthdocuments-check: checklist formatpush-gate: no push in the[~]stateowner-validate: path ownership
name-check(scripts/check-name.mts, RED tests intests/check-name/): no tracked file or path names the library by its old namestatus. Each occurrence is judged on its own: the spans that an allowed entry names are removed from its line, and the rest must hold no such word. The allowed entries are a state value of the consumer status column (its accessors and column words), an exit, HTTP or test result word of a process or request, the vendored kit, the CHANGELOG bullets up to the rename (S-01 to S-18) and the rename words of the plan and the rules; each entry is inALLOWEDwith its reason and matches exact phrases only (R12).- K19: a commit that moves an item to
[o]must also change the CHANGELOG. One commit changes the state of at most one item. - Review rules (no tool)
- Branch/worktree naming and deletion right after merge
- No external sources in git files (only adopt/not-adopt verdicts, in generic wording). polyspec repositories (kit, orm, hyper, crudui, template and others) are internal libraries and may be cited.
- Imperative commit subject, What/Why body
- RED→implement→GREEN order
- No fallback, no backward compatibility
- Experimental code removed
- No reopening
[o] - WIP control (no growing number of
[~]; finishing comes first) - Technical terms in English
- Basis of judgement: evidence-based decisions, no lowered standard, adversarial verification, decisions recorded in the related document
- The same tree gives the same result (kit tools enforce it for their own checks only)
- Each checklist item states an observable RED → GREEN criterion
- Test location (compiler, DB store and GUI tests in their packages; only consumer-side tests in language packages)
- Parallel runs only through
make -j6inpackages-test; only owner checks during an item - Commit messages in English
- A conformance check function asserts the expected value of its vector (the parity check counts cases, not assertions)
- Dependency pins (release tags in manifests and lock files)
- kit tools
- Branches and worktrees
- Work directly on main is allowed.
- A branch or worktree is named
{type}/{shortname}-{id}/fsm-{shortname}-{id}and is deleted right after merge. - Experimental code is removed before commit. If it cannot be removed at once, a sub-item records the removal condition.
B. State machine definition (fixed in docs/spec/fsm.md)
The definition (B-0 to B-6) is fixed in docs/spec/fsm.md (S-02); that document is the only source of the definition.
C. Structure and enforcement tools
C-1. Repository
fsm/
AGENTS.md(.ko) README.md(.ko) CHANGELOG.md(.ko) Makefile package.json package-lock.json PRINCIPLES.md(.ko) .node-version rust-toolchain.toml .githooks/
.gitignore # anchored only: /var/, /.results/, /node_modules/, /packages/*/node_modules/, /packages/*/vendor/, /packages/*/target/, /packages/python/**/__pycache__/, php-ext phpize output under /packages/php-ext/src/ (exact patterns fixed in S-14; config.m4 and generated sources stay unignored), /packages/gui/build/, /packages/gui/public/assets/, /packages/php*/.phpunit.result.cache, /packages/php*/.phpunit.cache/
config/{checklist,documents,owner-checks,commits,toolchain,dependency-policy,dependency-review,parity,gen-check}.json config/layout.yml
scripts/kit/kit-bootstrap.mk scripts/kit/bootstrap/ config/layout.kit.yml ... # copies of @polyspec/kit (kit.copies; make kit-sync, make kit-check)
scripts/check-name.mts scripts/check-readmes.mts scripts/gen-check.mts tests/support/ tests/check-name/ tests/check-readmes/ tests/docs-build/ tests/gen-check/ tests/python-tools/
contracts/features.json
docs/spec/fsm.md(.ko) docs/plans/execution-checklist.md(.ko) docs/plans/fsm-v1.md(.ko)
docs/package.json docs/package-lock.json docs/.vitepress/config.mts # the documentation site (C-8)
contracts/: the fsm project of the repository (S-44-5): fsm.config.json machines/order.fsm.json fsm.lock.json schema/fsm.schema.json
schema/persist.dbs schema/dbspec-set.json # persist.dbs = shared by the six language package examples
conformance/vectors.json
packages/compiler/ {src/core/, src/backends/, src/store-json/, src/store-lock/, src/store-mermaid/, src/io/, src/cli/, tests/} # imports only the set of C-5 (no npm package)
packages/db/ {schema/fsm.dbs, schema/dbspec-set.json, src/, tests/} # DbStore + fsm-db CLI, depends on orm
packages/gui/ {app/app.json, templates/, client/main.ts, public/assets/, build/, src/, crud/*.yml, tests/}
packages/{go,js,php,php-ext,python,rust}/ # the kinds go, npm, php, php-ext, python and rust; the npm kind lives in packages/js (S5)- External dependencies are pinned to release tags: orm v0.0.14, ordered-json v0.0.8, hyper v0.0.13, template v0.0.5, crudui v0.0.5, mermaid (exact npm version).
- npm: the release asset URL of every
@polyspec/*package independencies, the transitive ones too, since npm 12 fetches only the URLs that package.json names (--allow-remote=root); each override of a transitive one is the reference$<name>to that dependency - composer: the release zips of orm and ordered-json in an
artifactrepository, the foldervar/composer-artifactsthat the composer.json of each Composer package names, as orm and ordered-json document it (they publish to no registry):config/release-assets.jsonpins each zip by its version, URL and sha256,make release-assets-fetchof kit, which the install of each Composer package runs, downloads it and checks the sha256 before Composer reads it, and composer.lock records its dist with its shasum;config.platform.php8.5.0 - go:
require github.com/polyspec/orm v0.0.14 - python:
polyspec-orm @ https://github.com/polyspec/orm/releases/download/v0.0.14/polyspec_orm-0.0.14-py3-none-any.whl, the wheel of the release, which names the wheel of ordered-json v0.0.8 with its sha256 - cargo:
{ git = "https://github.com/polyspec/orm.git", tag = "v0.0.14" } .node-versionholds an exact 26.y.z release (required by hyper; kit toolchain-declared.mjs accepts only<major>.<minor>.<patch>).
- npm: the release asset URL of every
- The pins live in the manifests and lock files and are listed in
config/dependency-policy.json. The kit gate does not check npm URLs, the Composer artifact zips, python wheel URLs or cargo tags; pin verification is a review rule (no R requires a tool for it), except the python lock, whichmake python-lock-checkchecks (the decision below).- Decision (python lock, S-34, S-44): the python tree is locked by
packages/python/pylock.toml(PEP 751), the one Python lock format of kit, which kit'smake python-lock(ONLINE) writes with the uv ofconfig/toolchain.jsonfrompyproject.toml(the requirements) andconstraints.txt(a pin of every package, transitive ones too:name==versionfor a registry package,name @ <url>#sha256=<digest>for a polyspec wheel), and which kit'smake python-lock-check(offline) checks against them (kit K54).config/dependency-policy.jsonnames the lock and its sources inpythonLocks, so the kit gate and the review record (config/dependency-review.json) cover every transitive python package. fsm's own uv.lock, its export and itslock-checkare removed (S-44): they were a second lock of the same tree, and fsm'spython-lock-checkreplaced the kit target of the same name.make -C packages/python installinstalls pylock.toml withuv pip install --link-mode copyinto the venv that kit'smake python-venv DIR=packages/python/.venvmakes from the install-tools Python, so uv checks each artifact against its hash (measured in S-34: a tampered wheel hash is refused withHash mismatch for pyyaml==6.0.3);make installremoves the venv before it makes a new one. orm and ordered-json are pinned by their wheel assets asname @ <wheel asset URL>#sha256=<digest>. The build backendsetuptools==84.0.0is pinned by version in pyproject.toml and built in an isolated environment, which uv does not hash.
- Decision (python lock, S-34, S-44): the python tree is locked by
C-2. Canonical structure of a language package (identical for all six)
packages/<folder>/ # the first root of the kind in config/parity.json
README.md README.ko.md
Makefile # gen (gen + vectors --kind; flags only here; DEST ?= .)
# test (run-tests --cwd packages/<folder> --results .results/<kind>.json; paths resolve against the checkout root)
<manifest> # [allowed difference] depends on orm (SQLite) for tests and examples
<lock> # [allowed difference] go.sum, package-lock.json, composer.lock, Cargo.lock (read by the kit dependency gate); python has `pylock.toml`, which kit's `make python-lock` writes from `pyproject.toml` and `constraints.txt` and `uv pip install` reads (S-34, S-44)
src/… # fsm-gen output only
tests/conformance.<ext> # hand-written check functions; except in rust, iterates the vectors and registers one test per case; rust tests/conformance/main.rs
tests/vectors.<ext> # fsm-gen output (language-native data); in rust tests/conformance/vectors.rs, one #[test] per case
tests/persist.<ext> # the persist feature units of config/parity.json: concurrency and deadlock retry (S-44-9); go persist_test.go, php PersistTest.php, python test_persist.py
examples/persist.<ext> # B-4 orm persistence; tests run it with the persist vectors
examples/model/ # orm-generated model from the root contracts/schema/persist.dbs (committed, covered by gen-check); rust uses build.rs as an allowed differencemake genproduces the fsm-gen output and the orm-gen output together; the globs of the kind inconfig/gen-check.jsonname the files it writes (the kinds are the implementations ofconfig/parity.json, S-44), andgen-checkcompares exactly those files.
| kind | manifest | src | tests |
|---|---|---|---|
| go | go.mod | src/order/order.go, src/fsm/fsm.go (in Go a package is a directory) | conformance_test.go (generic: it runs each case through the adapter of its machine), vectors_test.go (generated: the vector cases, the key tables of every machine and one adapter per machine, which implements the interface of conformance_test.go; its only imports are the machine packages <module>/src/<machine>, so vectors --kind go takes --go-module; the persist example belongs to the test bed and is not imported, because decide returns the func type that examples.Decide names (S-26-1-1)) |
| npm | package.json | order.js/.d.ts, fsm.js/.d.ts, index.js/.d.ts (export * as order) | conformance.test.mjs, vectors.mjs |
| php | composer.json | Order/{State,Event,Step}.php, Order/functions.php, Fsm/Code.php (error code enum, its own file for PSR-4), Fsm/Error.php, and the loader functions.php (requires every machine's Order/functions.php; composer autoload.files names only this file, because PSR-4 cannot load functions) | ConformanceCase.php (hand-written checks), ConformanceTest.php (generated, one method per case) |
| php-ext | composer.json (type: php-ext, extension-name, build-path: src) | config.m4, order.c, fsm.c (shared <ns>\Fsm\Error and error code constants), php_<ext>.h, <ext>.stub.php, <ext>_arginfo.h | tests byte-identical to php; make test builds with phpize, then runs with --php-extension <.so>; gen requires --php-namespace and --php-ext-name |
| python | pyproject.toml | polyspec_fsm/{__init__,order,fsm}.py | test_conformance.py (generates methods named exactly after the cases), vectors.py |
| rust | Cargo.toml (autotests=false, autoexamples=false, [[test]] conformance, build-dep polyspec-orm-build, dev-dep tokio) + build.rs | lib.rs (pub mod order; pub mod fsm;), order.rs, fsm.rs | tests/conformance/main.rs (mod vectors;, #[path="../../examples/persist.rs"] mod persist;, Cargo's layout of a test target of several files); persist cases are #[tokio::test]; generated tests/conformance/vectors.rs starts with #![allow(non_snake_case, reason = …)]; tests/persist.rs and tests/common/mod.rs hold the persist feature units (S-44-9) |
make layout-check(kit, alint overconfig/layout.ymland the common rules ofconfig/layout.kit.yml) fails when (S-44):- a folder or file of the root, of
packages/or of the roottests/is not one that the layout declares (S5, S7), or a folder underpackages/carries thefsm-prefix, or a go.mod module path is notgithub.com/polyspec/fsm/<folder> - a kind package lacks a canonical file (
README.md,README.ko.md,Makefile), a canonical folder (src,tests,examples) or a required file of its kind, or holds a file that its kind does not allow - the php and php-ext
tests/files are not byte-identical PYTHONPATHappears, a path leaves the repository, or an infrastructure script underscripts/ortests/is not TypeScript (S9) The allowed files beyond the required set (tests/common/mod.rsof rust, the dialect module of its two test crates) carry their reason in a comment above their rule.
- a folder or file of the root, of
- Kinds are added one at a time by the package items.
C-3. Result records (K18)
- The counts that kit
test-progress.mjswrites inclose()gaincases: [{id, status, elapsedMs}]. - With
run-tests --results <file>, the runner adds{tool, inputs, elapsedMs}and writes the file once through a temporary file + rename. inputs= kitinputsDigest(root, paths), built from the paths and sha256 of tracked and non-ignored new files underpaths(the package directory andcontracts/schema/persist.dbs).- A case name is
<featureId>__<vectorId>, thenameof the case in contracts/conformance/vectors.json. go runs it as the subtestTestConformance/<name>, npm as the node:test test<name>, rust asvectors::<name>, php as the data set<name>of the one methodtestConformanceof ConformanceTest (K137; PSR-1 refuses a method name with underscores), and python as the methodtest_<name>: the names that kit's parity check (K33,config/parity.json, S-42-1) reads.- Both ids match
^[a-z][a-z0-9]*(_[a-z0-9]+)*$and contain no__. - A vectorId starts with
<machine>_. - Language tests use this name as is.
- Both ids match
C-4. Vector parity (kit K33)
kit's parity check (make parity-check, K33 of kit 0.0.26, config/parity.json; S-42-1) runs the conformance test of every kind twice and fails, with no exemption, when
- a kind did not run a case of
contracts/conformance/vectors.jsonthat itskindsadmits (a case withoutkindsis admitted by every kind), ran a case that itskindsexcludes, or ran a case that the vectors do not hold; - one run reports a case twice;
- a case fails, is skipped or is a todo, or the binding exits with a nonzero exit code.
make parity-declarations-check checks the declaration without running anything. The feature coverage (every feature of contracts/features.json has a case) is asserted by the compiler test vectors-file.test.mjs. The records .results/<kind>.json stay the output of run-tests; no fsm tool reads them.
The declaration follows the parity schema of kit K75 (S-44-3): no implementation sets PYTHONPATH (the python conformance test puts its own tests/ directory on the module path, beside src/ and examples/); the generated vectors_test.go of go and the generated *_test stub and header of php-ext are support files, which hold no test; and the node tests of the compiler, the DB store, the GUI and the root tools are tools roots, single-language tests that implement no contract. fsm declares one profile of every kind, so no profile needs spec anchors, and no kind declares symbols yet.
contracts/features.json = {schema: 1, features: [initial, code, key, from_code, is_final, events_from, target, apply, persist]}. The vector file is contracts/conformance/vectors.json = {machines: [{machine, cases: [{feature, id, name, …}]}]}, one suite for kit's parity check; the case name is name = <feature>__<id>.
Conformance machines (machines/, S-25): every kind runs the vectors of every machine in machines/, so the definition's shapes are covered by all six kinds: order (the example); standby (no events: one state, which is initial and final); in_review (underscore keys in the machine, state and event keys); ticket (reserved codes 2 and 4, which are states removed after a first lock; the lock keeps them as reserved); limits (the code 2147483647). Each kind passes 209 cases (kit's parity-check).
Vectors (exhaustive; the core generates the oracle):
initialper state: the
code/key/from_coderound trip,is_final,events_fromtargetfor every (state, event)for each defined (expected, event)
- actual = expected →
Changed - actual = to →
Unchanged - every other defined state →
E_CONFLICT
- actual = expected →
undefined (state, event) →
E_INVALID_TRANSITIONfrom_code: 0, an unknown code, each reserved code, a negative code, 2^31 and the 64-bit maximum (each E_UNKNOWN_CODE, except the reserved code)invalid state values (S-26): 0 and the unknown code for
code,is_final,keyandapply(expected or actual), in the kinds of the table belowpersist: first attempt / retry / conflictevery error case names the four fields of fsm.Error (
code,expected,actual,event, docs/spec/fsm.md, B-6); a field that does not apply is null: E_CONFLICT has the state codes of expected and actual and the event position, E_INVALID_TRANSITION the code of expected and the event position, E_UNKNOWN_CODE and E_RESERVED_CODE nonean input of
from_codeis a decimal string, so the 64-bit range is exact in the file; the npm kind receives the nearest number, which is an unsafe integer and therefore E_UNKNOWN_CODECase ids: a case id is
<machine>_<suffix>, and every key in the suffix is replaced by its declaration index, so that no two ids can collide, whatever the names are. A state iss<i>, an evente<j>(both 0-based) and a reserved coder<code>. The ids are<m>_initial;<m>_code_s<i>,<m>_key_s<i>,<m>_from_code_s<i>,<m>_is_final_s<i>,<m>_events_from_s<i>;<m>_from_code_zero,<m>_from_code_unknown,<m>_from_code_r<code>,<m>_from_code_negative,<m>_from_code_int32_overflow,<m>_from_code_int64_max;<m>_code_v<value>,<m>_is_final_v<value>,<m>_key_v<value>,<m>_apply_expected_v<value>_e0_s0,<m>_apply_actual_v<value>_e0_s0(value is 0 and the unknown code);<m>_target_s<i>_e<j>;<m>_apply_s<i>_e<j>_s<k>(expected, event, actual);<m>_persist_first,<m>_persist_retry,<m>_persist_conflict. The case data fields keep the keys (state, event, expected, actual, ...); only the ids use indexes.Persist cases exist only when the machine has a transition.
conflictexists only when a third state exists, so a two-state machine hasfirstandretry, and a machine without a transition (such as a one-state machine) has no persist case.Invalid state values (S-26): whether a state argument can hold a value that is no state depends on the state type of the kind. A case with an invalid value names the kinds that admit it in
kinds, and kit's parity check requires it of those kinds only. The decision per kind, recorded here for the spec B-6 rule that the functions fail with E_UNKNOWN_CODE:kind state type invalid state value go State int32admitted: the default branch of the switch is E_UNKNOWN_CODE npm a number; the state is its value admitted python an int (the members of IntEnum; the functions take any int)admitted rust enum Statewith#[repr(i32)]not admitted: the type has no value that is no state php, php-ext enum State: intnot admitted: the type has no value that is no state The native vector modules of rust, php and php-ext hold the cases that their kinds admit (core
forKind), so they run 224 of the 270 cases of the five machines; go, npm and python run all 270.Concurrency and retry of the persistence pattern (S-37, S-44-9, decision): B-4 promises the row lock and the deadlock retry in every kind, so every kind proves them in its persist test (go
tests/persist_test.go, npmtests/persist.test.mjs, php and php-exttests/PersistTest.php, pythontests/test_persist.py, rusttests/persist.rs), the four feature units of config/parity.json: two transitions of one row at the same time with the same event (one Changed, one Unchanged) and with two events (one Changed, one E_CONFLICT), each on its own connection (goroutines, threads, tokio tasks, two processes of pcntl_fork in PHP, which runs one thread), and a DEADLOCK error of orm inside the transaction that runs it again while another database error does not (injected through the machine step, or the fault hook of the rust example). The persist cases run on PostgreSQL and MySQL throughmake <kind>-persist-test-postgresandmake <kind>-persist-test-mysql(S-37-1):FSM_TEST_DIALECTselects the dialect,FSM_TEST_PG_DSNandFSM_TEST_MYSQL_DSNname the database, each case clears the persist table, and the cases of a server dialect run one after another (rust takes a static lock; the other kinds run sequentially). Each target stops with exit 2 when its DSN is empty (require_dsnin the Makefile), and the test fails too. RED per kind: before the test readFSM_TEST_DIALECT, the target passed with 11 persist cases on SQLite under the PostgreSQL target (rust, php, php-ext, python); go failed withdriver "postgres" is not registereduntil its example importedorm/pg. On SQLite the two transitions run one after the other, because the SQLite driver is synchronous: a secondBEGIN IMMEDIATEin the same process blocks the thread that would commit the first, and fails withCANCELED: sqlite: database is lockedafter the 5000 ms busy timeout. RED: withforUpdate()removed fromexamples/persist.mjs, both transitions report Changed on PostgreSQL 17.11 and on MySQL 8.4.11, and both tests fail; the restored pattern passes on the three dialects.Vector parity (S-42-1, decision): the vectors run on kit's parity check (K33, kit 0.0.26 with K47), which runs every binding and admits cases per kind; fsm's own scripts/check-parity.mjs, which read the saved records, is removed, since an existing tool does the check (S-35-2 and S-42 had kept it while kit lacked per-case admission).
C-5. compiler packages/compiler
- Imports (S-32):
packages/compiler/srcimports only (1) node built-ins, by theirnode:specifier; (2) its own files, by a relative path that stays insrc. It imports no file of kit: the package@polyspec/kitexports only its tool APIs, sosrc/io/files.mjswrites a file atomically andsrc/io/git.mjsruns git withnode:fsandnode:child_process(S-44-1). No npm package, no bare built-in name and no dynamicimport()orrequire().src/coreimports nonode:module at all.packages/compiler/tests/imports.test.mjsscans every import specifier ofsrcand fails on anything else. - Parts
src/core: object → validate → normalize → backends, plusrunandcopy. It has zeronode:imports, and the GUI imports it as is.src/backends/<kind>.mjssrc/store-json,src/store-mermaidsrc/cli(fsm-gen)
- The command
fsm-genis thebinofpackages/compiler/package.json(a manifest without dependencies). From a clone,npm install -g ./packages/compilerlinks it onto the PATH;tests/command.test.mjsinstalls the package offline into a temporary npm prefix and runs the command (S-18-1). - Commands
lock [--rename]gen --kind <k> --out <dir>(php requires--php-namespace; php-ext requires--php-namespaceand--php-ext-name; go requires--go-module <module path>, because a generated machine imports the shared package<module>/src/fsmand the backend cannot read go.mod)vectors [--kind <k>] --out <path>(go:--go-module <module path>, as for gen; the file imports only<module>/src/<machine>, S-26-1-1)check: validates every definition and the lock match, and the lock keeps every committed code (B-3)copy --to json|mermaid --root <dir>
- Root
gen-check- Run
fsm-gen vectors --out var/gen-check/conformance/vectors.json. - In each package of
kinds, runmake gen DEST=$(CURDIR)/var/gen-check/<kind>. scripts/gen-check.mtscompares with the working tree throughtrackedFilesof@polyspec/kit/tracked-files(tracked and non-ignored files only, so ignored outputs such as__pycache__/and the phpize output in php-extsrc/are not read).contracts/conformance/vectors.jsonand each package'ssrc/,tests/vectors.<ext>andexamples/model/(except rust) must hold the same file set with byte-identical content; the compared files of each package are the ones matched by its kind'sgeneratedglobs incontracts/layout.json, and a kind without them is alayout-configfinding.
gen-check-test(tests/gen-check/) RED: a one-byte change fixture, an added file fixture, an ignored file that must not be read
- Run
C-6. fsm-db packages/db
- The command
fsm-dbis thebinofpackages/db/package.json. Aftermake db-deps,npm install -g ./packages/dblinks it onto the PATH;tests/command.test.mjsinstalls it offline into a temporary npm prefix, installs the schema into a new SQLite database and checks it (S-18-1). packages/db/schema/fsm.dbs→ orm-generated model (npm).- Definition tables
machine(name PK)(machine order is by name)state(machine, key, code i32, position, initial, final): PK(machine,key), unique(machine,code), unique(machine,position)event(machine, key, position): PK(machine,key)edge(machine, event, from, to, position): PK(machine,event,from). FK (machine,event)→event, (machine,from)·(machine,to)→state, with leading indexes
- Lock tables
lock_code(machine, code i32, key): PK(machine,code)lock_reserved(machine, code i32): PK(machine,code)- No FK to machine, because the lock of a machine removed from the definitions must remain.
- Definition tables
DbStoreimplements the store contract.savereplaces everything inside one transaction. Validation is finished by corerunbefore the store is called.- The
fsm-dbCLI runs corerunandcopywith DbStore and the json/mermaid stores.installinstallspackages/db/schema/fsm.dbsthrough ormutils().schema().install(S-15-1). The commands and their behaviour are indocs/spec/fsm.md(B-2) andpackages/db/README.md. - Tests (SQLite)
- store contract (round trip and byte-identical results)
E_STORE_NOT_EMPTY- CLI run as processes:
installon a new database (checkfails before it with no table and passes after it), a secondinstall, a partialinstallrejected by orm, and the usage ofinstall - transaction atomicity RED
- dialect differences are orm's own conformance responsibility
- Tests (S-37):
make db-testruns every test of the package on SQLite.make db-test-postgresandmake db-test-mysqlruntests/db-store.test.mjs,tests/store-contract-all.test.mjs,tests/cli.test.mjsandtests/command.test.mjson PostgreSQL and MySQL. Both files open the database throughtests/dialect.mjs:FSM_TEST_DIALECTnames the dialect (sqliteby default,postgresormysql),FSM_TEST_PG_DSNandFSM_TEST_MYSQL_DSNname the database, and a missing DSN throws, so the target fails and no case is skipped; the target itself stops first with exit 2 (require_dsnin the Makefile). Each file installs the schema (install changes nothing on a database that holds it) and clears the six tables before a case, so a case reads only what it wrote; the files run with--test-concurrency=1, because they share the server. The CLI and command tests take an empty database of their own (the next bullet). - Empty databases (S-37-2, decision): the CLI and command tests need a database with no fsm table, and a shared server has none. Each case takes its own empty database. On SQLite it is a new file of the case's temporary directory. On PostgreSQL and MySQL,
createEmptyDatabase(packages/db/tests/dialect.mjs) creates a database namedfsm_test_<pid>_<time>_<random>through the admin DSN (FSM_TEST_PG_ADMIN_DSNorFSM_TEST_MYSQL_ADMIN_DSN; locallypostgres://fsm@127.0.0.1:55432/fsm_test?sslmode=disableandmysql://root@127.0.0.1:33306/mysql). PostgreSQL makes the user ofFSM_TEST_PG_DSNthe owner of the new database; MySQL grants every privilege on it to each account of the user ofFSM_TEST_MYSQL_DSN, which it finds inmysql.user. The case connects with the DSN of its dialect and the new database in its path, so the server and the user stay those of the variable. Theafterhook of the file drops every database that it created (DROP DATABASE ... WITH (FORCE)on PostgreSQL). A statement that orm does not give (DROP TABLEof a partial schema) runs through the driver of the dialect (runStatement), with the devDependenciespg8.23.1 andmysql23.24.5 of fsm-db, the versions that orm v0.0.7 resolves. Considered and not taken: one database per file (the cases change the tables, so each needs an empty one); dropping the six tables before each case (the install case needs no table, and orm has no drop); one schema per case (MySQL has no schema below a database). The missing-table check names the quoted tablemachinein each message, because the PostgreSQL server of the tests writes its messages in Korean. - Local servers (S-37, the runs of this item): PostgreSQL 17.11 (
initdb,pg_ctl, data directory and socket directory of the run) and MySQL 8.4.11 (mysqld --initialize-insecure,--daemonize), each on its own port (55432 and 33306), with the databasefsm_testand the userfsm. The DSNs werepostgres://fsm@127.0.0.1:55432/fsm_test?sslmode=disableandmysql://fsm:fsm@127.0.0.1:33306/fsm_test. The servers were stopped and their data directories removed after the runs. - CI services for S-21 (the lines to copy into the job that runs
make installandmake check; the images are the versions above):
yaml
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: fsm
POSTGRES_PASSWORD: fsm
POSTGRES_DB: fsm_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U fsm -d fsm_test"
--health-interval 5s --health-timeout 5s --health-retries 10
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: fsm
MYSQL_PASSWORD: fsm
MYSQL_DATABASE: fsm_test
ports: ['3306:3306']
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
FSM_TEST_PG_DSN: postgres://fsm:fsm@127.0.0.1:5432/fsm_test?sslmode=disable
FSM_TEST_MYSQL_DSN: mysql://fsm:fsm@127.0.0.1:3306/fsm_test
FSM_TEST_PG_ADMIN_DSN: postgres://fsm:fsm@127.0.0.1:5432/fsm_test?sslmode=disable
FSM_TEST_MYSQL_ADMIN_DSN: mysql://root:root@127.0.0.1:3306/mysql- The concurrency and persist tests of npm (
make npm-persist-test,-postgres,-mysql) read the same variables; their decision is recorded in C-4.
C-7. GUI packages/gui (hyper Node server + crudui + mermaid)
- The GUI is a single-editor tool. save replaces everything; the last write wins.
- Build:
hyper-build-assets --app . --api /api --output build(hyper v0.0.13 requires--app,--apiand--output) producestemplates.index.json,manifest.jsonand thepublic/assetsbundle (run before gui-test). - Run:
fsm-gui [--project <dir>] [--port <n>], thebinofpackages/gui/package.json. Aftermake gui-build,npm install -g ./packages/guilinks it onto the PATH;tests/command.test.mjsinstalls it offline into a temporary npm prefix and requests the machine list from it (S-18-1).- The store follows
fsm.config.json. App.open({manifest, templates: {index: build/templates.index.json, root: public}, handlers, timezone: 'UTC'})with absolute paths;var/sessionsis created beforeFileSessionsopens itapp.server(new FileSessions({directory: var/sessions}), {files: public})
- The store follows
- Screens
- Machine list (crudui list)
- Machine detail: states, events and edges, Mermaid diagram
- The canonical output of the mermaid store goes into
<pre class="mermaid">. client/main.tsimports npmmermaidand callsmermaid.run()on first load and on everyhtmx:after:settle.- The bundle is served from
public/assets, so it works offline.
- The canonical output of the mermaid store goes into
- State, event and edge forms
- rename
- Forms (crudui)
- The server loader/action runs generator-core
compileForm/createForm→ generator-htmlrenderForm(instance, {action, hidden: {_csrf}, errors, formErrors})and puts the resulting string into the template with{= form | raw}. client/main.tsbinds the edit form with cruduiconnectForm(crudui-form, instance)and subscribes to the instance (S-22-1). Every change of the instance runspatchContent(host, renderForm(instance, render))and thenconnection.sync(), the contract of form-runtime.md.hostis the#fsm-formelement that wraps the server's form.renderis the page's crudui render options (editRenderOptions: the action, the hidden_csrfand the errors of the page), embedded in the page state, so the re-rendered markup equals the server markup for the same data. Before S-22-1 a click on a row's add or remove button changed the instance but not the page, because nothing re-rendered it; the browser test found it.- A row added in the browser takes the next code (
client/main.tswrites the codes into the DOM after each re-render), and a remove takes the row out of the instance. crud/*.ymlholds no validation rules, and form-binding is not used. The only rules are the core's.src/form-data.mjsparses the flatform[…]names of a submitted body into crudui row-key objects. An invalid or duplicate name isBadRequest(with a RED test).- A core error carries
{machine, path}(the definition JSON path).- The GUI converts it to a crudui data path and puts it in
errors. - An error with no node (such as
E_KEY_RESERVED) goes intoformErrors. - The response is
Result.invalid({form})(422).
- The GUI converts it to a crudui data path and puts it in
- The server loader/action runs generator-core
- Rules
- Every save goes through core
run save. - The core rejects a key change on an existing code with
E_CODE_KEY_CHANGED. Renames happen only on the rename screen (lock --rename). - The GUI owns no rules.
- Every save goes through core
- Tests
- hyper handler unit tests (node:test)
checkpasses after a save- crudui owns form rendering correctness; hyper owns routing correctness.
- Browser test (S-22-1,
make gui-browser-test):tests/browser/browser.test.mjsdrives the edit form in a headless Chromium of the pinned Playwright package (playwright1.64.0, an exact devDependency ofpackages/gui;make gui-browsersdownloads the browser, so it is ONLINE). The browser is the stampvar/deps/playwright-<version>(keyed on the version inpackages/gui/package-lock.json, S-29-1): the first run downloads it, a later run with the stamp present downloads nothing.gui-browsersis a phony alias of that stamp for the command line, so the file target is the one the browser test needs; the alias is a deviation from the S-29-1 wording, chosen so thatmake gui-browserskeeps working. It runsnode src/main.mjs --project <temp> --port 0on a temporary project holding the repository'smachines/order.fsm.jsonandfsm.lock.jsonand{"store":"json"}, and checks in order: add on the last state row adds a row with the suggested code 6; a final staterefundedwith the cancel edge shipped to refunded is saved to the machine and the lock, and the show page lists it with its Mermaid svg; remove of that row and its edge, then Save, removes the state and reserves code 6; the next added row suggests 7. The first run, before the fix, failed at the first assertion (5 !== 6: the click left the rows unchanged). gui-testruns the top-leveltests/*.test.mjsfiles only ($(wildcard ...)):node --teston a directory also runs the nested files, so the browser test intests/browser/would otherwise need a browser ingui-test.- Readiness (S-22-3): the browser test waits for the form's own readiness, not for
networkidle.client/main.tssetsdata-fsm-connected="true"on the form host#fsm-formafterconnectFormand the subscription are in place, and removes it when the connection is dropped (before a rebind).openEditandconnected()in the test wait for#fsm-form[data-fsm-connected="true"]. The layout's CSS (#fsm-form:not([data-fsm-connected]) button[data-crudui-action] { pointer-events: none }) keeps the row buttons from taking a click until the marker is set. - The first-run failure (S-22-3), cause: the first navigation of a newly downloaded Chromium is slow, not a click before the bind. Measured on four fresh downloads (
PLAYWRIGHT_BROWSERS_PATHset to an empty directory, thenplaywright install chromium): the first case took 4.7 s, 12.4 s (failed at the 5 s limit), 2.7 s and 2.5 s, every later case at most 1.3 s. The client cannot be bound late: the bundle is atype="module"script in the head, which runs beforeDOMContentLoadedand delaysload, and 30 cold trials of a click right afterload(with CPU throttles and latencies) all added the row. So the setup of the browser test opens the edit page once with a 60 s limit, which loads the whole bundle, and every case keeps its 5 s limit. The markerdata-fsm-connectedstays as the explicit readiness signal that the cases wait for, instead of network idleness. - Decision (row buttons before connection): the buttons take no pointer click until the marker is set (CSS above). The window exists in principle, because the form is painted before the module runs on a slow network. Keyboard activation of a focused button before the connection is not blocked by this rule (a known gap; the form does nothing then, as before).
- Decisions: the browser test uses the Playwright release that the dependency review of 2026-10-11 names as latest (1.64.0, recorded with
make dependency-review UPDATE=1 RECORD=1). - Forms and icon (S-22-2): the crudui stylesheet is part of the bundle.
make gui-buildcopies@polyspec/crudui-generator-core/styles/crudui.csstopublic/assets/crudui.cssand passes it tohyper-build-assets --static, which puts a stylesheet of the layouts into the static deployment; the layout links/assets/crudui.css. The build could not take the stylesheet as an import ofclient/main.ts: esbuild would write a second output, and the build refuses any output that is not ahyper-chunk-*file (the client build wrote N entry files). The icon ispublic/favicon.ico, an ICO file, because browsers request/favicon.icoand the hyper server answers only files ofpublic/(by extension:.icoisapplication/octet-stream); no route answers with raw bytes.scripts/make-favicon.mjswrites it. The stored key of a row is labeledKeyand the new key of a new state is labeledNew key, the label of the rename form's new key. The browser test checks eachbutton[data-crudui-action]for its crudui size (28px), its icon mask on::beforeand an accessible name, the distinct labels of each row, and the ICO answer of/favicon.ico.
C-8. Documentation site (docs/.vitepress, make docs-build)
- Build:
make docs-buildrunsdocs-deps(npm ci --no-audit --no-fundfromdocs/package-lock.json), then VitePress builds the site intodocs/.vitepress/dist.docs/node_modules,docs/.vitepress/distanddocs/.vitepress/cacheare git-ignored. - Install scripts (S-20-2): npm 11 warned
npm warn install-scriptsfor two install scripts thatdocs/package.jsondid not decide (itsallowScriptsfield), so a fresh install printed a warning on everymake docs-deps. Traced: (1)esbuild@0.25.12(postinstallnode install.js) replaces the command shimbin/esbuildwith the platform binary and checks its version. The JS API that vite uses does not need that replacement: esbuild'slib/main.js(generateBinPath) resolves@esbuild/<platform>/bin/esbuilditself. The script is approved for the pinned version ("esbuild@0.25.12": true), which keeps the installed state as it was. In the unconfigured install the postinstall still ran (the shim was replaced), so the warning did not mean a skip in npm 11.19; the decision does not depend on it. (2)fsevents@2.3.3(macOS only, an optional dependency of vite's watcher, chokidar) ships its prebuiltfsevents.node(163,626 bytes, the same size with the scripts skipped), andrequire('fsevents')loads afternpm ci --ignore-scripts. The site runs no watcher, so the script is denied ("fsevents": false). The testthe docs install prints no npm warning(tests/docs-build) runs a freshnpm ciof the lock and fails on any warning line; it failed on these two lines before the decision. - Versions:
docs/package.jsonpinsvitepress1.6.4 exactly (the release of the template repository). It also overridesviteto 6.4.4, because vitepress 1.6.4 requiresvite ^5.4.14, and the 5.4.x line has moderate and high advisories with no fixed release (npm auditof the first lock). 6.4.4 has none, and pulls esbuild 0.25.x.make dependency-review RECORD=1records the lock with no advisory. The site runs no dev server; the override is accepted becausemake docs-build-testpasses with it. - Source: the documents are read in place, with no copy and no generated Markdown. The VitePress srcDir is the repository root. The include list is:
README.mdandREADME.ko.md;packages/*/README.mdandpackages/*/README.ko.md;docs/spec/**;docs/plans/fsm-v1.mdanddocs/plans/fsm-v1.ko.md;docs/verification/**;CHANGELOG.mdandCHANGELOG.ko.md. Every other Markdown file is in srcExclude, computed from a walk of the repository at build time, and so arenode_modules,.git,var,vendor,targetanddocs/.vitepress. A page outside the include list is not built. - Images: a document shows a local image with a relative path (
docs/verification/gui.mdshows the GIFs of the GUI manual check beside it); VitePress bundles it, so a missing image fails the build, and the dead-link test oftests/docs-buildcopies the images of every document into its mirror of the site. - Languages: there are no VitePress locales, because a locale needs a directory prefix. Each Korean document is its own page, and each page links its twin.
- Rewrites: a
README.mdrenders asindex.mdof its directory, so the root is/and a package is/packages/<name>/. AREADME.ko.mdkeeps its own route,/README.ko. A relative link works in both, because twins share a directory. - Repository links: a link from a site page to a repository file or directory that is not a site page (for example
AGENTS.md,packages/db/schema/fsm.dbsorMakefile) is rewritten tohttps://github.com/polyspec/fsm/blob/main/<path>(treefor a directory) by the markdown-it rulerepositoryLinksofdocs/.vitepress/config.mts. A link to a file that does not exist is left as written and fails the build.ignoreDeadLinksis not set. - Navigation: the nav and the sidebar each have an English group and a Korean group (no locale switch). A group holds Overview, Specification, Plan, Packages, Verification and Changelog. Packages has one entry per package whose README pair exists;
fsm-compilerandfsm-dbjoin when their READMEs are written (S-19). Verification appears when its pages exist. - Base:
VITEPRESS_BASE(default/). The GitHub Pages site of polyspec/fsm is served under/fsm/, so the Pages workflow of S-21 setsVITEPRESS_BASE=/fsm/. Site routes take the base; the links to GitHub are absolute. - Markup in prose: VitePress compiles Markdown as Vue templates, so an angle-bracket placeholder in prose (for example
<major>) fails the build. Such a placeholder is written in a code span. The<a id="...">anchors of the changelogs are HTML and render. - Vue on the root pages: the pages sit at the repository root, where no
node_modulesholdsvue.docs/.vitepress/config.mtsaliasesvueto its runtime entrydist/vue.runtime.esm-bundler.js(theimportcondition of its exports) andvue/server-rendererto its ESM entry indocs/node_modules, so one copy of vue is bundled. - Circular chunk (S-20-1): the build printed
Circular chunk: framework -> theme -> framework. It is removed, and its cause is recorded here with its evidence. Reproduced:make docs-buildprinted the line. Not the cause: the vite 6.4.4 override (the same vitepress and configuration with vite 5.4.21 printed it too), and vitepress itself (a minimal vitepress 1.6.4 project with its default configuration printed nothing). The cause: the alias ofvuetovue/index.mjs, which isexport * from './index.js', the CommonJS wrapper ofdist/vue.cjs.js. The manual chunks of vitepress put the ESM@vue/*modules intoframeworkand the wrapper intotheme, which closes the cycle; a minimal project with that alias printed the line. The fix aliasesvuetodist/vue.runtime.esm-bundler.js, andmake docs-buildprints no warning.docs-build-testfails on a warning line of the build output: a line that starts with(!),warnorwarning, or names a circular chunk or dependency (tests/docs-build). - Tests:
tests/docs-build(make docs-build-test, a root tool test with the record.results/docs-build-test.json) builds, in a temporary copy of the repository documents, a document with a link to a missing file, and requires the build to fail with a dead link. Then it builds the repository itself, and requires the pagesindex.html,README.ko.html,packages/go/index.html,docs/spec/fsm.htmlanddocs/plans/fsm-v1.html, the GitHub rewrite ofAGENTS.md, and no page forAGENTS.md, and no warning line in its output (S-20-1).docs-buildanddocs-build-testare inCHECK_TARGETS, with their owner rules inconfig/owner-checks.json.config/documents.jsonsetssiteLinks, sodocuments-checkaccepts the link forms of the site: a link that starts with/, and a link without extension that names a.md,.ko.mdorindex.mdfile.
C-9. User documentation (R11)
- The README pairs are the ones that
scripts/check-readmes.mtsdeclares inREADME_PAIRS: the root,fsm-compiler,fsm-db,fsm-guiand the six language packages. - Each pair has an English file and a Korean twin. The check requires the English
##headings of its kind (the list is fixed in the script) and a Korean twin with the same number of headings per level. A heading inside a fenced code block is not a section. - Kinds of sections: root (what fsm is and is not, the model, codes and the lock, transitions, validation errors, project files, requirements and setup, the fsm-gen CLI, the tools, generated names, checks); compiler (architecture, the store contract, commands, backend options, vectors); db (what it is, set up, the six tables, DSN, commands, moving a project to the database, checks); gui (what it does, setup, running, screens, rules; its testing section is written by S-22-2); language (generate into your project, generated files, API, errors, persistence, tests).
- The command names in the documents are
fsm-gen,fsm-dbandfsm-gui, the bare names of the commands that S-18-1 installs; the install and invocation are in the root README, and no document runsnode packages/.... - Every documented command is run for real through the installed command, in a temporary copy of a project, and its output is pasted. A code excerpt is copied from a repository file with its path. A snippet that is not a repository file is run in a temporary consumer project before it is written, and its output is quoted with it (the go, npm, php, python and rust APIs).
- A Korean fenced code block is identical to its English block (
documents-checkrequires it), so code and command output are not translated; the prose around them is. - The tests are
tests/check-readmes/(make readme-test);make readme-checkruns the check on the repository.
C-10. CI and Pages (S-21)
.github/workflows/ci.ymlruns on every push to main and on a manual run (workflow_dispatch). Its one job,check, runs on ubuntu-26.04 (orm's PHP client requires SQLite 3.46, and the PHP of ubuntu-24.04 links the system SQLite 3.45.1, which failed the php persist cases withCAPABILITY_UNSUPPORTEDin CI run 38120456615; orm's own CI runs on ubuntu-26.04 for the same reason) and calls exactly two targets:make install, thenmake check, the full suite of CHECK_TARGETS. Beforemake checkit runsnpx playwright install-deps chromiuminpackages/guiwith sudo (the system libraries of the Chromium thatgui-browser-testdrives; the browser itself is installed bymake checkthroughgui-browsers).- The job starts PostgreSQL 17 and MySQL 8.4 as services and sets the four
FSM_TEST_*variables. Its services and env are the yaml block of C-6, copied exactly;make workflows-testrequires the job to contain that block, so the plan and the workflow cannot drift apart. - The versions come from the files that declare them: Node.js from
.node-version(setup-node); Go frompackages/go/go.mod(setup-go, no module cache); PHP fromconfig/toolchain.json(php, through setup-php with the extensionspdo_pgsqlandpdo_mysql, which the php-ext build and the persistence tests need); Rust fromrust-toolchain.toml, which names the channel, the minimal profile and the clippy component that kit'slint-rustruns, installed byrustup toolchain installwith no argument (S-44), becauseRUSTUP_AUTO_INSTALLis0. - uv is declared in
config/toolchain.json(uv: the exact release and the sha256 of each platform's archive, S-44-2), andmake installinstalls it intovar/toolsthrough install-tools; the workflow installs no uv of its own. uv installs the interpreter of the pythonBuild ofconfig/toolchain.jsonand resolves and installs the python package.make workflows-testrequires the declaration and refuses a uv install in the workflow. - Every action is pinned by its full commit with a version comment (
# vX, or the tag where the tag has nov). The commits are the ones that the template repository verified for the same versions. - The job uploads
var/full-run.jsonand.results/as an artifact under!cancelled(), so a failed run still keeps its record. .github/workflows/pages.ymlruns on every push to main and on a manual run. It runsmake docs-buildwithVITEPRESS_BASE=/fsm/(the base of the site, read bydocs/.vitepress/config.mts), uploadsdocs/.vitepress/distwith upload-pages-artifact and deploys it with deploy-pages. The site is published at https://polyspec.github.io/fsm/.- Both workflows set
permissions: contents: readat the top level, and their concurrency group does not cancel a run: a newer push does not cancel the run of an earlier one. - The tests are
tests/workflows/(make workflows-test). They read the workflows as text and need no network: every pin, the two make calls of the check job, the services and env, the versions, the artifact, the base of the site and the deployment.
D. Checklist and owner checks
The state of every item is in docs/plans/execution-checklist.md, the only checklist of this plan (AGENTS.md, Checklist). This section holds no state. The kit items K17 to K19 are items of the kit repository, not of this checklist; section E names them.
owner-checks mapping (each item adds its own part):
| Path | Owner checks |
|---|---|
scripts/kit/**, package.json, package-lock.json, the other copies of kit, config/**, Makefile, .gitignore, .node-version, rust-toolchain.toml | kit-check owner-validate documents-check commits-check toolchain-check dependency-policy-check |
.githooks/** | hooks-check |
**/*.md | documents-check |
config/layout.yml, config/layout.kit.yml | layout-check |
config/gen-check.json, config/parity.json | gen-check packages-test parity-declarations-check |
scripts/gen-check.mts, tests/gen-check/** | gen-check-test gen-check |
tests/python-tools/** | python-tools-test |
packages/python/pyproject.toml, packages/python/constraints.txt, packages/python/pylock.toml | python-lock-check |
packages/compiler/**, contracts/** | compiler-test gen-check |
contracts/schema/persist.dbs | gen-check packages-test |
packages/db/schema/fsm.dbs, packages/db/** | db-test |
packages/gui/** | gui-test |
packages/{go,js,php,php-ext,python,rust}/** | layout-check gen-check test-<kind> dependency-policy-check |
- The owner check of a kind is
test-<kind>(its package tests); kit'sparity-checkruns the vectors of every kind incheckafterpackages-test(S-42-1). - Independent items may run in parallel when the checklist allows it (AGENTS.md, Checklist).
- The full suite runs once with
make checkafter the last item of the checklist is committed.
E. Order of work
- kit K17–K19 → kit release → fsm from S-01.
- Audits during implementation follow the audit rules of section 0. The auditor gives the exact location and replacement text, each finding is judged against its evidence, and the loop repeats until there are no core disagreements.
Verification
The root Makefile defines these targets after
include scripts/kit/kit-bootstrap.mk:CHECK_TARGETS,check- kit's
layout-check, and kit'sparity-declarations-checkandparity-check compiler-test,db-test,gui-test,gen-check-test,gen-checkpython-tools-test(tests/python-tools: the Python of the checkout, S-41:var/tools/bin/python3is made byinstall-tools, no recipe installs an interpreter with uv, there is noinstall-pythontarget, andconfig/toolchain.jsondeclares thepythonBuild;make -non a copy of the Makefile, no network)workflows-test(tests/workflows: the CI and Pages workflows, their pins, versions and services, C-10, S-21)test-<kind>,packages-test(thetest-<kind>ofkindswithmake -j6)
check=node $(KIT)/full-run.$(KIT_EXT) run $(CHECK_TARGETS)CHECK_TARGETS=kit-check owner-validate documents-check commits-check hooks-check toolchain-check dependency-policy-check layout-check lint-ts compiler-test gen-check-test gen-check db-test db-test-postgres db-test-mysql npm-persist-test npm-persist-test-postgres npm-persist-test-mysql gui-test npm-types name-test name-check docs-build docs-build-test packages-test gui-browser-test readme-test readme-check python-tools-test make-install-test rust-persist-test go-persist-test-postgres go-persist-test-mysql rust-persist-test-postgres rust-persist-test-mysql php-persist-test-postgres php-persist-test-mysql php-ext-persist-test-postgres php-ext-persist-test-mysql python-persist-test-postgres python-persist-test-mysql python-lock-check workflows-test parity-declarations-check parity-check
Python interpreter (S-41, S-44-2): the python package runs on the Python that
make install-toolsinstalls.config/toolchain.jsondeclaresuv(the exact release and the sha256 of each platform's archive) andpythonBuild(3.14.8); install-tools installs uv intovar/toolsand then, withuv python install, the python-build-standalone build that uv names for that release, checked against the sha256 that uv records, and writes the wrappervar/tools/bin/python3, whichmake installand every python target use (kit K64). No recipe of fsm installs an interpreter, and the uv-managed interpreter ofinstall-python(S-11-3) stays removed. uv is the resolver and installer ofpackages/python(uv pip install --python <venv>/bin/python --link-mode copy, with the venv thatmake python-venvmakes fromvar/tools/bin/python3).pythonBackendis not declared, because the repository publishes no wheel.Package dependencies (S-29):
make install(ONLINE, once per clone) installs the toolchains ofinstall-toolsintovar/toolsand every package dependency:npm ciof the locks ofpackages/js,packages/db,packages/guianddocs;composer installofpackages/phpandpackages/php-ext(keyed oncomposer.lockandcomposer.json, because the autoload ofcomposer.jsonis not in the lock, and the stamp is touched after the install, S-29-2); the Python 3.14 ofinstall-tools(var/tools/bin/python3, S-41) and the venv ofpackages/python(made by kit'smake python-venvfrom that interpreter and filled from itspylock.toml; the python stamp is keyed onpylock.toml, S-44); the go modules and the cargo crates (var/deps/goandvar/deps/rust). Each one is a file target keyed on its lock, so a later run finds it up to date and needs no network. The targets that use a package name its file as a prerequisite:npm-types,gen-check,test-<kind>(and sopackages-testand kit'sparity-check),db-test,gui-test,gui-browser-test(through the browser stamp),docs-buildanddocs-build-test. A fresh clone runsmake installonce, thenmake check: the toolchains come only frommake install.make make-install-test(tests/make-install/) checks the prerequisites on a copy of the Makefile, with no dependency installed and with every dependency present.During item work only the owner-checks mapping runs.
GUI manual check, for each of the json, mermaid and db stores (the steps, the driver
packages/gui/scripts/manual-check.mjsand the recorded outcome are indocs/verification/gui.md):- Add, delete and rename states, then save (save updates the lock too).
checkpasses (fsm-gen checkfor json and mermaid,fsm-db checkfor db).- Mermaid renders in the browser.