Line

You are reading the unreleased docs (next). See latest (v0.9)

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), CLI research 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 existing generateWorkflow with { stateUnion: true }: initial is the state marked initial; each state becomes { type: final ? 'final' : undefined, on: {...} }; transitions are grouped by (from, event) (SPEC-006 already 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 payload becomes 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's compileWorkflow: every guard a transition names gets a setup({ guards: {...} }) entry that returns false with 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 to features/<feature>/types.ts (the feature's shared types, re-exported by its index.ts) as export type Name = ...;. A name already declared there is left as written and reported.
  • One function stub per functions[] entry, through defineService (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. Its input/output type strings are parsed and re-printed via parseTypeString, 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 --unit for the feature: a locked features/<feature>/tests/generated/<machine>--every-path.test.ts that walks the machine with @xstate/graph. The first run declares the frozen: and nonLayer: test regions in architecture.yml (as create proof does; 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, xstate and tsx in the project (construct init already lists them; otherwise the command prints the npm install -D line).

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).
  • --generate fills nothing in: guards return false, stubs throw. Implementing them is the job of a person, or of a model that gets one stub at a time and must pass validate and tsc.
  • 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.