Internals
Machine spec
An English requirement, checked: states, events, transitions and typed functions, each traced to its sentence.
machine-spec.v1 is the JSON contract between the one fuzzy step (someone — a person, a form, or a
model — breaks an English requirement into states, events, transitions and functions) and everything
deterministic after it: construct research spec refuses a spec that cannot become a real
machine or that silently dropped a sentence; only an accepted spec goes on to generate the state union,
the XState machine, the typed function stubs and the machine's every-path unit test (R2, --generate,
below). The generated project passes construct validate and tsc --strict, and its unit test runs.
- Schema:
packages/core/research/machine-spec.v1.schema.json(draft-07). - Validator:
packages/core/research/machine-spec.mjs(validateMachineSpec,renderMachineSpecReport). - Generator, R2:
packages/core/research/specToCode.mjs(buildWorkflowDescriptor,functionStubSource,generateFromSpec). - Worked example (passes):
packages/core/research/examples/machine-spec.v1.example.json. - Failing examples, one per refusal:
fixtures/machine-spec/{unreachable-state,untyped-function,uncovered-sentence,unknown-type}.json. - Drafting from English with a model, R3:
packages/core/research/specFromRequirement.mjs(splitRequirement,buildDraftPrompt,draftMachineSpec), CLIresearch spec draft, below.
To draft one with a model, hand it the schema plus the worked example — not instructions. The example is the documentation.
The shape (from the worked example, "sign-in with retry")#
{
"version": 1,
"name": "sign-in with retry",
"feature": "auth",
"requirement": [
{ "id": "s1", "text": "A visitor signs in with an email and a password." },
{ "id": "s4", "text": "If the credentials are invalid, the visitor sees the reason and can try again." },
{ "id": "s6", "text": "The sign-in page must load in under two seconds." }
],
"outOfScope": [
{ "req": "s6", "reason": "A performance budget, not machine behaviour: checked by a Lighthouse threshold in CI." }
],
"states": [
{ "id": "idle", "initial": true, "req": ["s1"] },
{ "id": "failed", "description": "The reason is shown; the form is editable again.", "req": ["s4"] },
{ "id": "signedIn", "final": true, "req": ["s3"] }
],
"events": [
{ "id": "SUBMIT", "payload": "{ email: string; password: string }", "req": ["s1"] },
{ "id": "RETRY", "req": ["s4"] }
],
"transitions": [
{ "id": "t1", "from": "idle", "to": "checking", "event": "SUBMIT", "req": ["s1", "s2"] },
{ "id": "t3", "from": "checking", "to": "lockedOut", "event": "INVALID", "guard": "attemptsExhausted", "req": ["s5"] },
{ "id": "t5", "from": "failed", "to": "idle", "event": "RETRY", "req": ["s4"] }
],
"functions": [
{
"name": "recordFailedAttempt",
"input": "{ attempts: number }",
"output": "{ attempts: number; exhausted: boolean }",
"precondition": "attempts is a non-negative integer (0 before the first failure).",
"postcondition": "attempts is one higher than the input; exhausted is true exactly when the new count is 3 or more.",
"req": ["s5"]
}
],
"types": [
{ "name": "Session", "definition": "{ userId: string; token: string }" }
]
}
| Field | What it is |
|---|---|
requirement[] |
The English, one sentence per entry, verbatim, with an id. Reading order. |
outOfScope[] |
Sentences that deliberately produce nothing, each with a reason. |
states[] |
id, optional initial (exactly one), final, description. |
events[] |
id, optional payload (a TypeScript type), description. |
transitions[] |
from, to, event, optional guard (a named boolean condition), id, description. |
functions[] |
name (a TypeScript identifier), input and output (TypeScript types; void for none), precondition, postcondition. |
types[] |
Optional named types (name, definition, description) that functions and event payloads may refer to, e.g. Session. A type string may name only these, the built-in utility and platform types (BUILT_IN_TYPES in machine-spec.mjs), or write its shape inline. |
req |
On every state, event, transition and function: the sentence id(s) it came from. |
ext |
Free-form, ignored by validators. |
Every sentence is either claimed by some item's req or listed under outOfScope — never neither,
never both.
The command#
construct research spec <file> [--generate [--feature <name>] | --read-back | --coverage [--feature <name>]] [--format json|text] [--dir <path>]
Exit 0 when the spec passes, 1 on any SPEC-* failure, 2 when the file cannot be read or is not JSON
(or, with --generate, when neither the spec nor --feature names a feature). --format json prints
the same shape as construct validate --format json — { status, file, counts, violations }, each
violation with rule, module, severity, file, path (the item inside the spec, e.g.
transitions[2].to), message, why, expected — so a Cockpit table or a model retry loop can point
at the exact item.
$ construct research spec fixtures/machine-spec/unreachable-state.json
❌ SPEC-007 [machine-spec]
fixtures/machine-spec/unreachable-state.json at states[5]
State "passwordReset" cannot be reached from the initial state "idle".
Why: A state nothing leads to is either a missing transition or a state the requirement never asked for.
Expected: a transition into it from a reachable state
1 problem(s) in fixtures/machine-spec/unreachable-state.json — fix them and run again; nothing is generated from a spec that fails.
--generate, R2: spec to code#
On an accepted spec, --generate calls packages/core/research/specToCode.mjs's generateFromSpec
to write real code, deterministically, no LLM:
- The workflow, via
packages/engine/workflowGenerator.mjs's existinggenerateWorkflowwith{ stateUnion: true }:initialis the state markedinitial; each state becomes{ type: final ? 'final' : undefined, on: {...} }; transitions are grouped by(from, event)(SPEC-006already guarantees the grouping is never ambiguous), guarded entries ordered before the unguarded fallback so XState tries them first. Also writes<Name>WorkflowState.ts(the typed state union and exhaustive matcher). - Typed events: an event's
payloadbecomes the event's own members in the generated union ({ type: "SUBMIT"; email: string; password: string }, XState's flat-event convention; a payload that is not an object literal is intersected,{ type } & Payload), and the declared types it names are imported from../types. - A named stub per guard, in
workflowGenerator.mjs'scompileWorkflow: everyguarda transition names gets asetup({ guards: {...} })entry that returnsfalsewith a// TODO:comment naming the req sentence(s) the guarded transition exists to satisfy, so a transition never points at a dangling guard name, even before the real predicate is written. - The declared
types, appended tofeatures/<feature>/types.ts(the feature's shared types, re-exported by itsindex.ts) asexport type Name = ...;. A name already declared there is left as written and reported. - One function stub per
functions[]entry, throughdefineService(packages/core/typed-contracts/factories.ts): the v1 schema has no per-function layer field, so every function lands in the service layer; a future schema version could add one. Itsinput/outputtype strings are parsed and re-printed viaparseTypeString, the declared types they name are imported from../types, its precondition, postcondition and req sentence text land as comments directly above the stub, and the body throws rather than returning a fabricated value. - The machine's every-path unit test, by calling
construct generate tests --unitfor the feature: a lockedfeatures/<feature>/tests/generated/<machine>--every-path.test.tsthat walks the machine with@xstate/graph. The first run declares thefrozen:andnonLayer:test regions inarchitecture.yml(ascreate proofdoes; a half-declared project is refused before anything is written). A feature that already has other machines gets their tests refreshed too, since that command works per feature. The test needs@xstate/graph,xstateandtsxin the project (construct initalready lists them; otherwise the command prints thenpm install -Dline).
Never overwrites an existing file, same policy as construct init's project scaffold
(packages/core/scaffold.mjs): a target that already exists is reported Skipped ... (already exists, not overwritten), so running the same spec against the same project twice writes nothing new
the second time (the unit test is byte-identical, so it is Unchanged). --feature is used only when
the spec itself has no feature field; it is a usage error (exit 2) for neither to name one.
$ construct research spec packages/core/research/examples/machine-spec.v1.example.json --generate
✓ machine-spec.v1 ... passed — 6 sentence(s): 5 covered, 1 out of scope; 5 state(s), 4 event(s), 5 transition(s), 3 function(s)
Wrote features/auth/types.ts
Wrote features/auth/index.ts
Wrote features/auth/workflows/SignInWithRetryWorkflow.tsx
Wrote features/auth/workflows/SignInWithRetryWorkflowState.ts
Wrote features/auth/services/verifyCredentials.ts
Wrote features/auth/services/recordFailedAttempt.ts
Wrote features/auth/services/openDashboard.ts
Wrote features/auth/tests/generated/signinwithretry--every-path.test.ts
Updated architecture.yml (declared frozen and nonLayer for the generated tests)
Updated features/auth/types.ts (added Session)
Generated feature "auth": 8 file(s) written, 0 skipped.
features/auth/services/verifyCredentials.ts (one of the three function stubs):
import { defineService } from '<relative path to @construct/typed-contracts>';
import type { Session } from '../types';
// Generated by `construct research spec --generate` from a machine-spec.v1 function entry.
// Precondition: email and password are both non-empty strings.
// Postcondition: Resolves { ok: true, session } when the pair matches an account; otherwise { ok: false, reason } with a reason that does not say which of the two was wrong.
// req: s1 ("A visitor signs in with an email and a password."), s3 (...), s4 (...)
export const verifyCredentials = defineService("verifyCredentials", (props: {
email: string;
password: string;
}): Promise<{
ok: true;
session: Session;
} | {
ok: false;
reason: string;
}> => {
throw new Error("Not implemented: verifyCredentials (see the precondition/postcondition comment above).");
});
The generated project passes construct validate, compiles with tsc --strict and its every-path unit
test passes: test/specToCode.test.mjs runs all three for real, not as a claim.
Known gaps#
- Every function lands in the service layer (no per-function layer field in v1).
--generatefills nothing in: guards returnfalse, stubs throw. Implementing them is the job of a person, or of a model that gets one stub at a time and must passvalidateandtsc.- English to spec (drafting the JSON with a model, R3) is
research spec draft, below.
research spec draft, R3: a model drafts the spec, the checks decide#
construct research spec draft <requirement-file> --llm <provider> [--feature <name>] [--out <file>] [--format json|text] [--dir <path>]
<requirement-file> is plain English, one sentence per line (as a Note or a story reads, );
splitRequirement turns it into requirement[] deterministically (s1, s2, ... in reading order,
blank lines dropped) -- the only fuzzy step is what the model does with those sentences. The prompt
(buildDraftPrompt) is the schema plus the worked example above, never prose instructions. The model's
reply is parsed and run through the same validateMachineSpec research spec uses; a draft that fails
gets ONE corrected retry with its own previous reply and the concrete SPEC-* violations fed back, so
the model corrects specific problems instead of a blind identical retry. Only a draft that passes is printed (--out also writes
it) -- a draft that still fails after the retry prints the same violation report research spec would
and exits 1; a provider failure (missing CLI, dead daemon) exits 3 and is never retried.
Every accepted or rejected draft (never a provider failure -- that is not a decision) is recorded as a
decision-trace (docs/BLOCK-CONTRACT.md, "AI-ready by design"): a closed two-option chooser
(research.spec.draft, accepted/rejected), attributed by: 'llm' with the provider name, so a
drafting run is replay-scorable without the spec's content ever leaving the trace as free text.
Mechanical fallback stays available with no model configured: construct research spec <file> on a
hand-written or form-filled spec, unchanged by any of this.
--read-back, R4: the spec in plain English#
construct research spec <file> --read-back [--format json|text]
The person who wrote the requirement (or approved a model's draft of it) confirms what was understood
before anything is generated. On an accepted spec, --read-back prints, per requirement sentence in
order, the sentence and then the states, events, transitions and functions whose req points at it,
each in plain words. An item that claims several sentences shows under each of them; a sentence marked
out of scope is listed as such with its reason; the declared types (they carry no req) come last,
so nothing is dropped. On a spec that fails a SPEC-* check it prints the same report as plain
research spec and reads nothing back. It writes nothing and is exclusive with --generate (exit 2).
The wording is the workflow narrator's (packages/engine/workflowNarrator.mjs), not a second
vocabulary: the flow summary is narrateMachine's, every transition is transitionSentence's (a guarded
branch and its "Otherwise" fallback read exactly as construct research workflow reads them). No model.
$ construct research spec packages/core/research/examples/machine-spec.v1.example.json --read-back
Read-back of "sign-in with retry" (feature auth)
The "sign in with retry" flow has 5 steps. It starts in *idle* and can end in *signed in* or *locked out*.
s1: A visitor signs in with an email and a password.
- *idle*: The flow starts here. The form is shown and editable.
- "submit" (`SUBMIT`) is something that can happen, carrying `{ email: string; password: string }`.
- In *idle*: When "submit" happens, the flow moves to *checking*.
- `verifyCredentials` takes `{ email: string; password: string }` and gives back `Promise<...>`. It expects: ...
...
s5: After three failed attempts in a row, the visitor is locked out and must reset their password.
- *locked out*: An end state, the flow stops here. No more attempts; only a password reset gets the visitor out.
- In *checking*: When "invalid" happens, the flow moves to *locked out* — only if the "attempts exhausted" condition holds.
- `recordFailedAttempt` takes ...
s6: The sign-in page must load in under two seconds.
Out of scope: A performance budget, not machine behaviour: checked by a Lighthouse threshold in CI, not by this workflow.
--format json is the same content as a fixed-shape result, construct.machine-spec-readback.v1:
{ schema, name, feature, summary, sentences, types, counts }, where each sentences[] entry is
{ id, text, status, reason, states, events, transitions, functions } and each item is
{ id, req, text }. Ids are the spec's own (sentence s1, state idle, event SUBMIT, transition
t1, function name), so they stay stable across runs and across edits to other items. status is
covered, out-of-scope or (only for a spec that was never validated) uncovered. The library
entry points are readBackMachineSpec(spec) and renderReadBack(result, { format }) in
packages/core/research/readBack.mjs. The output for the worked example is pinned by
test/machineSpecReadBack.test.mjs against fixtures/machine-spec-readback/.
--coverage, R5: which functions and files realize each sentence#
construct research spec <file> --coverage [--feature <name>] [--format json|text] [--dir <path>]
SPEC-010 already refuses a sentence nothing claims; --coverage answers the same question as a map, and
the other way round. Per requirement sentence, in order: covered (with the functions, and the states,
events and transitions, that claim it, and so the generated files it ends up in), out-of-scope (with the
spec's reason) or uncovered (nothing claims it and it is not out of scope: the one status to act on).
Then, per function, the sentences it cites, or orphan when none of its req links resolves to a sentence
of the requirement (a link to an id the requirement does not have is listed as unknownReq, the
SPEC-009 case). Then, per generated file, the sentences it serves. It writes nothing and is exclusive
with --generate and --read-back (exit 2). No model.
The paths are the R2 generator's own: plannedSpecFiles in packages/core/research/specToCode.mjs,
which generateFromSpec itself calls, and a test asserts the report names exactly the files --generate
writes. A function maps to its defineService stub (services/<name>.ts); a state, event or transition
maps to the machine's three files (the workflow, its typed state union, the locked every-path unit test),
because the generator writes the whole machine into those, not one file per item. The declared types carry
no req, so types.ts is not listed. Like --generate it needs a feature name (the spec's feature, or
--feature; exit 2 with neither). The unit test's file name is the machine's, so a feature that already
holds another machine with the same id numbers its test -2 and the reported path is then off by that suffix.
Exit 0 unless a sentence is uncovered and not out of scope (exit 1). A function that cites no sentence is
reported but does not fail the run on its own. The report also runs on a spec whose only violations are
SPEC-009 and SPEC-010, since showing those is its job; a spec that breaks any other rule prints the
plain validation report and no coverage, as --read-back does.
$ construct research spec fixtures/machine-spec-coverage/gap.spec.json --coverage
Coverage of "contact form" (feature contact)
Sentences
s1: A visitor sends a message with the contact form.
function sendMessage -> features/contact/services/sendMessage.ts
machine (states idle, sent; events SEND; transitions t1) -> features/contact/workflows/ContactFormWorkflow.tsx, ...
s2: The visitor gets a confirmation email.
NOT covered: no state, event, transition or function cites it, and it is not marked out of scope.
s3: The form must look good on a phone.
Out of scope: A visual quality bar, checked by review, not by this workflow.
Functions and the sentences they cite
sendMessage: s1
notifyTeam: cites no sentence of the requirement (unknown link s9)
...
3 sentence(s): 1 covered, 1 out of scope, 1 NOT covered; 2 function(s), 1 citing no sentence; 5 generated file(s).
--format json is a fixed-shape result, construct.machine-spec-coverage.v1:
{ schema, name, feature, sentences, functions, files, counts }. Each sentences[] entry is
{ id, text, status, reason, functions, machine: { states, events, transitions }, files }; each
functions[] entry { name, file, status: 'cites' | 'orphan', req, unknownReq }; each files[] entry
{ path, kind: 'workflow' | 'state' | 'test' | 'service', sentences }; counts is { sentences, covered, outOfScope, uncovered, functions, orphanFunctions, files }. Ids are the spec's own (sentence, state, event,
transition, function name), so they stay stable across runs and edits to other items. The library entry
points are coverageOfMachineSpec(root, spec, { feature }) and renderCoverage(result, { format }) in
packages/core/research/coverage.mjs. The worked example and a fixture with a gap are pinned by
test/machineSpecCoverage.test.mjs against fixtures/machine-spec-coverage/.
The checks#
Structure first (SPEC-001), then meaning only once the structure is sound.
| Code | Refuses |
|---|---|
SPEC-001 |
Anything outside the schema's shape: unknown, missing or mistyped field (path names it). |
SPEC-002 |
An id used twice in the same list (sentences, states, events, transition ids, function names, out-of-scope entries). |
SPEC-003 |
Not exactly one state marked initial. |
SPEC-004 |
A transition whose from or to is not a declared state (expected lists the states). |
SPEC-005 |
A transition whose event is not a declared event. |
SPEC-006 |
Two transitions with the same from, event and guard — the second could never fire. |
SPEC-007 |
A state not reachable from the initial state by walking the transitions. |
SPEC-008 |
A function with no input or output type. |
SPEC-009 |
A req (or outOfScope.req) naming a sentence that is not in requirement. |
SPEC-010 |
A sentence neither covered by any item's req nor listed under outOfScope. |
SPEC-011 |
A sentence listed under outOfScope that an item still claims. |
SPEC-012 |
A transition leaving a final state. |
SPEC-013 |
A type string (function input/output, event payload, a types definition) that is not a valid TypeScript type. |
SPEC-014 |
A type string naming a type that is neither built in nor declared under types (it would fail tsc in the generated code). expected names the entry to add. |
The schema and the validator are kept in lockstep by test/machineSpec.test.mjs (ajv, dev-only):
what the schema can express, both refuse; the graph walk and the coverage checks are the validator's own.