Delivery spec format
A delivery spec states the behaviour of one domain of a product as structured, machine-checkable YAML: clauses in EARS form with stable IDs, tuning parameters with an approved range, scenarios that are the test oracle, the domain model, the design decisions with the owner’s answers, and the open questions. Work items bind to its clauses, and gates reason on its fields instead of parsing prose. This page is the reference for format version 0.2, in which the format stopped knowing anything about one project: a project profile declares the requirement catalogue, the ID shapes a link may take and the units a parameter may use, so a second project adopts the format unchanged. Why the format exists and how it fits into delivery is in Spec-driven delivery; the work-item side and readiness are in Spec readiness contract; what SupaCloud builds around it is in How SupaCloud integrates with a spec-driven project.
Layout
Section titled “Layout”One directory per domain under specs/. Only spec.yaml is mandatory.
specs/ project.yaml the project profile: catalogue, ID patterns, decision records, units _schema/ JSON Schemas (draft 2020-12) and versions.lock.json _template/ the complete example domain (TPL) — copy it to start a new one <domain>/ one directory per domain, lower-case, e.g. econ/ spec.yaml clauses, scope, glossary, state machines parameters.yaml tuning values with an approved range scenarios/*.yaml Given/When/Then examples; the test oracle model.yaml entities, commands, events decisions.yaml design decisions and the owner's answers questions.yaml open questions README.md narrative; not normative| Rule | Consequence |
|---|---|
| A domain holds only the six YAML file types above | any other *.yaml/*.yml in a domain directory is rejected, so a new file type always starts with a schema |
Scenario files use the .yaml extension |
a scenarios/*.yml file is rejected |
_schema is not a domain |
the validator skips it |
project.yaml is the profile, not a domain |
it is required, and a missing or malformed one fails the validator and the spec gate — see The project profile |
A directory whose name starts with _ (the template) |
is validated and gated like a domain, but owns no catalogue ID and never makes a work item ready |
README.md |
is narrative only; nothing in it is normative, and it cites clause IDs instead of restating them |
The project profile
Section titled “The project profile”specs/project.yaml (schema project/0.2) is what makes the format project-neutral. The JSON
Schemas name no project-specific ID shape and no unit list; the profile declares them, so a second
project adopts the format by writing its own profile rather than by editing the schemas. Paths in it
are relative to the repository root.
| Field | Declares |
|---|---|
catalogue.source |
the catalogue file, JSON or YAML, holding named lists of IDs |
catalogue.lists[] |
each list’s key and what it holds: requirements, acceptance_criteria or decisions |
catalogue.patterns[] |
the ID shapes a satisfies link may take: a name, a pattern and an example |
decision_records[] |
optional: record files (glob) whose file names start with an ID (pattern), so a decision recorded after the catalogue snapshot still counts as a catalogue entry |
units[] |
every unit a parameter may use: a snake_case name and its meaning |
- A catalogue list entry is an ID string or an object with a string
id. - A pattern must match a whole ID, and its own
examplemust match it. - Every ID the catalogue and the decision records hold must match a declared pattern, so a pattern cannot silently exclude part of the catalogue.
- The validator, the spec gate and the work-item depth check all read the catalogue through this one file.
There is no default and no fallback. Without a profile, or with a malformed one, the validator, the spec gate and the work-item depth check fail and name the file and the field. Nothing falls back to the reference implementation’s catalogue or units.
That the format really is project-neutral is a test, not a claim: in the reference implementation the
template domain is validated against a second, invented profile whose IDs are US-<n> and
DR-<nnnn> and whose only unit is cent. It passes there and fails against the reference
implementation’s own profile, naming every foreign link and the foreign unit
(tests/test_spec_schema.py::test_a_domain_validates_against_another_projects_profile).
Identifiers
Section titled “Identifiers”<DOM> is the domain code: 2 to 8 characters, an upper-case letter followed by upper-case
letters or digits (^[A-Z][A-Z0-9]{1,7}$). Every file declares it in its domain field, and
it must equal the code in spec.yaml. IDs are stable: once merged, an ID is never reused for
something else.
| Item | Pattern | Example |
|---|---|---|
| Clause | <DOM>-R-### |
ECON-R-012 |
| Parameter | <DOM>-P-### |
ECON-P-003 |
| Scenario | <DOM>-S-### |
ECON-S-021 |
| State machine | <DOM>-SM-## |
ECON-SM-01 |
| Decision | DEC-<DOM>-### |
DEC-ECON-002 |
| Question | Q-<DOM>-### |
Q-ECON-004 |
Every ID must carry its own domain’s prefix and appear once across all files of the domain.
The six ID shapes above belong to the format and are fixed in the schemas.
Catalogue links. satisfies fields point at the project’s requirement catalogue —
requirements, acceptance criteria and decision records — and those shapes are project data, not
format. The schema only requires a generic token (^[A-Za-z0-9]+(?:[._:-][A-Za-z0-9]+)*$, at most
64 characters); the project profile declares the patterns a link may match,
and the validator checks every link against them and against the catalogue. A link that matches
no declared pattern is rejected, and so is one that matches a pattern but has no catalogue entry. A
clause may only satisfy an ID that its own spec lists in scope.satisfies.
In the reference implementation the declared shapes are requirements (LH-F-023, PH-WLD-006),
acceptance criteria (LA-17, PA-03, A09-16) and ADRs (ADR-004); another project declares its
own and changes no schema.
spec.yaml
Section titled “spec.yaml”| Field | Type | Rules |
|---|---|---|
schema |
const | spec/0.2 |
domain |
string | the domain code |
title |
string | at least 3 characters |
status |
enum | draft, design-approved, implementing, verified — see Status |
owner |
string | the role that owns the domain’s decisions, e.g. project-owner |
summary |
string | at least 40 characters |
scope.satisfies |
list of catalogue IDs | required, at least one, unique: everything this domain is responsible for |
scope.paths |
list of globs | optional: repository-relative glob patterns of the code that implements the domain, in the same dialect as a work item’s allowed_paths (src/Fm.Economy/**); absolute paths, ./, drive letters, .. segments and backslashes are rejected |
scope.excludes |
list of strings | optional deliberate non-goals, each at least 10 characters and naming who owns it instead |
glossary[] |
term, definition |
term at least 2, definition at least 10 characters |
clauses[] |
clause | at least one — see Clauses |
state_machines[] |
state machine | optional — see State machines |
A clause may only satisfy an ID that its spec lists in scope.satisfies, and the spec gate
requires every ID in scope.satisfies to be satisfied by at least one clause.
Clauses
Section titled “Clauses”A clause is one testable statement in structured
EARS form. kind fixes which condition fields it carries;
shall is always the response.
kind |
Condition fields | Reads as |
|---|---|---|
ubiquitous |
none — when, while, if and where are rejected |
The system shall … |
event |
when required |
When …, the system shall … |
state |
while required |
While …, the system shall … |
unwanted |
if required |
If …, then the system shall … |
optional |
where required |
Where …, the system shall … |
complex |
at least two of when, while, if, where |
While …, when …, the system shall … |
| Field | Rules |
|---|---|
id |
<DOM>-R-### |
kind |
one of the six kinds above |
when, while, if, where |
at least 3 characters each, as the kind requires |
shall |
at least 10 characters: what the system shall do |
satisfies |
at least one catalogue ID, unique, each listed in scope.satisfies |
parameters |
optional parameter IDs the clause depends on; each must exist in parameters.yaml |
decisions |
optional decision IDs the clause depends on; each must exist in decisions.yaml |
rationale |
optional free text |
A number in a clause is a parameter, never a literal in prose. Naming the parameters and decisions a clause depends on means a change to either can be traced to every clause it affects.
State machines
Section titled “State machines”| Field | Rules |
|---|---|
id |
<DOM>-SM-## |
name |
snake_case |
states |
at least two, unique, snake_case |
initial |
one of the states |
final |
optional list of states; a transition may not leave a final state |
transitions[] |
at least one: from and to (states), trigger (a PascalCase command or event), optional guard text and optional clause (a clause of this spec) |
The transition key is trigger rather than on because YAML 1.1 reads a bare on key as a
boolean.
parameters.yaml
Section titled “parameters.yaml”Tuning values with an approved range. Values are integers, in line with a determinism contract that keeps floating point out of state.
| Field | Rules |
|---|---|
id |
<DOM>-P-### |
name |
snake_case |
description |
at least 10 characters |
unit |
a snake_case unit name (^[a-z][a-z0-9_]*$) that the project profile declares; there is no fixed list in the schema |
type |
int or fixed |
scale |
integer ≥ 1; required for fixed: the stored integer is the value times the scale |
default, min, max |
integers; min ≤ default ≤ max |
status |
proposed or approved |
decided_by |
a decision of this domain; required when approved |
notes |
optional |
A ratio of 0.125 stored as type: fixed, scale: 1000 is the integer 125. min and max
are the approved range: inside it, tuning is free and needs nobody; changing the range itself
is a design decision.
scenarios/*.yaml
Section titled “scenarios/*.yaml”A scenario is a concrete example with fixture values. Scenarios are the test oracle: they are
compiled into generated tests, so the expected values come from the spec and not from the
agent that implements it. In the reference implementation the generator is planned
(bw-fm27 WI-SPEC-004); the format is final.
| Field | Rules |
|---|---|
schema |
scenarios/0.1 |
scenarios[].id |
<DOM>-S-### |
title |
at least 5 characters |
covers |
at least one clause ID of this domain, unique |
given |
an object: the state before the step, as data |
when[] |
at least one step: command (PascalCase) and optional args object, in order |
then[] |
at least one check: path (dotted path into the resulting state or the command result), op and value |
op is one of eq, ne, lt, lte, gt, gte, contains, absent, rejected.
value is required for every op except absent and rejected.
model.yaml
Section titled “model.yaml”Entities, commands and events with payload fields. An additive change extends the project’s contracts; a breaking change is an architecture decision.
| Field | Rules |
|---|---|
entities[] |
name (PascalCase), optional description, fields (at least one), optional invariants (each at least 10 characters) |
commands[], events[] |
name (PascalCase), optional description, fields, optional emits (event names of this model), optional clause (a clause of this spec) |
fields[] |
name (snake_case), type, optional description, optional optional flag |
A field type is int, fixed, bool, text, date, tick, money, id<Entity>,
enum<a|b|c> or list<…>. money is eur_cent as a 64-bit integer; id<Entity> is a typed
stable ID. Names are unique across entities, commands and events, and field names are unique
within an item.
decisions.yaml
Section titled “decisions.yaml”An open design choice, the recommendation and the owner’s answer.
| Field | Rules |
|---|---|
id |
DEC-<DOM>-### |
title |
at least 3 characters |
question |
at least 10 characters |
blocking |
optional boolean: implementation cannot start before this is answered |
options[] |
2 to 12, each with key (snake_case, unique), title, description |
recommended |
the key of one option |
rationale |
at least 10 characters: why the recommendation |
references |
optional list of strings |
status |
proposed, approved or superseded |
answer |
required when approved: choice (an option key), answered_by, answered_at (date-time), optional note and source (where it was given, e.g. a questionnaire or a question-gate ID) |
superseded_by |
required when superseded: another decision of this domain |
The recommendation is part of the record, but it is not the answer. A decision is answered
only when answer is filled in by the owner — a pre-selected recommendation that nobody
confirmed stays proposed.
questions.yaml
Section titled “questions.yaml”A gap found while specifying or implementing. It is raised here and never settled by an assumption in code.
| Field | Rules |
|---|---|
id |
Q-<DOM>-### |
question |
at least 10 characters |
context |
optional |
clauses |
optional clause IDs of this domain the question concerns |
raised_by, raised_at |
who raised it, and when (date-time) |
status |
open, answered or withdrawn |
answer, decision |
an answered question names its answer, the decision that settled it, or both |
Status
Section titled “Status”spec.yaml carries exactly one status:
| Status | Meaning | What the gate enforces |
|---|---|---|
draft |
being written; not binding | nothing beyond validity |
design-approved |
the owner has taken the design decisions; work items may be derived | no decision proposed, no question open |
implementing |
work items implement it | as design-approved |
verified |
the implementation is proven against it | as design-approved |
A decision reopened or a question raised while a spec is design-approved or later turns the
spec gate red until it is answered or the spec returns to draft. Readiness follows the same
facts: a work item whose decision is no longer approved, or whose spec is back at draft, is
no longer ready. No rule yet tells implementing and verified apart; that is a later schema
version’s job.
The spec gate
Section titled “The spec gate”The validator proves that a domain is well formed. The spec gate adds the rules that tie a spec
to its requirements, its tests and its code. In the reference implementation it runs in
fmctl validate all and alone as fmctl validate specs; every finding names the file, the
field, the domain and the IDs involved.
| Rule | Fails when |
|---|---|
| Coverage | an ID in scope.satisfies is satisfied by no clause of the domain |
| Scenarios | a clause has no covering scenario, or a covers link names no clause of the domain |
| Status | a spec at design-approved or later still holds a proposed decision or an open question |
| Drift | a change under scope.paths comes with no commit message that names a clause of the domain |
| No spec | there is no domain directory at all, so the gate would otherwise pass by matching nothing |
| Profile | specs/project.yaml is missing or malformed, so no link and no unit could have been checked |
Catalogue IDs that no domain lists in scope.satisfies are reported by ID. That is information,
not a failure, but their count is frozen in docs/requirements/spec-coverage-baseline.json — and
this baseline must equal the current count, not merely bound it. A count above the baseline is a
regression; a baseline above the count is slack that would let the next regression pass unnoticed.
A pull request that gives a domain new catalogue IDs therefore rewrites the baseline in the same
change, with --write-baseline. (The reference implementation’s other baselines — unproven
requirements and acceptance criteria, shallow and unmigrated work items — are shrink-only without
the equality rule: they may fall freely and only raising one needs a named human decision.)
Drift compares a base with HEAD: the files of git diff <base>...HEAD and the clause IDs in
the messages of git log <base>..HEAD. The base is --base <ref>, else the environment
variable FM_BASE_REF, else the pull request base in GITHUB_BASE_REF. An explicit base that
does not resolve fails; without any base, or when a shallow checkout lacks the pull request
base, the drift rule is skipped and the gate prints why. A commit that changes domain code
names the clause it implements, e.g. feat(econ): pay wages weekly (ECON-R-012).
Validation and the version lock
Section titled “Validation and the version lock”uv run python tools/qa/check_specs.py # the spec gate, as validate all runs ituv run python tools/qa/check_specs.py --base origin/main # including drift against mainuv run python tools/qa/check_specs.py --write-baseline # after a domain takes on catalogue IDsuv run python tools/specs/validate.py # every domain, plus the version lockuv run python tools/specs/validate.py specs/econ # one domainuv run python tools/specs/validate.py --write-lock # after a schema changeuv run python tools/specs/validate.py --init-lock # only when there is no lock yetBeyond the JSON Schemas, the validator checks what a schema cannot express: IDs that belong to
their domain and appear once, links that resolve (catalogue, parameters, decisions, clauses,
events), min ≤ default ≤ max, state machines whose states exist, recommended and
answer.choice naming real option keys, and superseded_by naming another decision.
Versioning. Each schema requires its own version in the schema field. In format 0.2 that is
spec/0.2, parameters/0.2 and project/0.2; scenarios, model, decisions and questions
did not change and stay at scenarios/0.1, model/0.1, decisions/0.1 and questions/0.1. A
document that names an older version is rejected with the migration to apply — the finding points
at Migration rather than at the schema’s own error.
The version lock. specs/_schema/versions.lock.json records the accepting surface of every
schema, per schema and version, as a sorted list of tokens. What it sees:
| Token | Records |
|---|---|
clauses[].id |
a field |
clauses[].id!required |
a field that is always required |
clauses[].kind=event |
an enum value |
schema==spec/0.2 |
a const value |
domain~^[A-Z][A-Z0-9]{1,7}$ |
a pattern |
summary@minLength=40 |
a length, item-count or numeric bound |
clauses@type=array |
a type, or a list of types |
questions[].raised_at@format=date-time |
a format |
clauses[].decisions@uniqueItems=true |
items that must be unique |
clauses[]@additionalProperties=false |
the rule for fields the schema does not name |
clauses[].when!required if kind==event |
a field required under an if condition |
clauses[]!if kind==complex then anyOf(…) |
any other rule an if/then or else branch adds |
Bounds are minLength, maxLength, minItems, maxItems, minimum and maximum; an else
branch reads as if not(…). Descriptions are not part of the surface.
Which direction is breaking. A change that can reject a document the version before accepted is
breaking and needs a version bump; --write-lock refuses to record it otherwise:
| Breaking — needs a version bump | Additive — needs only a lock refresh |
|---|---|
| a field removed or renamed | a new field |
| an enum value removed | a new enum value |
| a field made required, always or under a condition | a required field made optional |
a new conditional rule (if/then, anyOf, not) |
a conditional rule removed |
| an enum or const on a field that had none; a changed const | an enum or const dropped |
| a raised minimum, a lowered maximum | a lowered minimum, a raised maximum |
| any change of a pattern — tightening cannot be told from relaxing in general | a pattern removed |
| a narrowed type, or a type on a field that had none | a widened type |
| a new or changed format | a format removed |
new uniqueItems |
uniqueItems dropped |
| an object closed to unnamed fields, or given a rule for them | an object opened again |
A constraint that arrives together with a new field is additive, because the version before rejected the field altogether.
The lock itself cannot be laundered. A missing lock fails validation. --write-lock only
refreshes an existing lock, so deleting the lock cannot turn a breaking change into a fresh,
innocent-looking one; --init-lock writes the first lock and refuses to overwrite one.
The schemas name no vendor, model or tool, so a spec never binds work to a provider.
Migration from 0.1 to 0.2
Section titled “Migration from 0.1 to 0.2”- Add
specs/project.yamlif the repository has none: the catalogue source and its lists, one pattern per ID shape thatsatisfieslinks use, the decision records if any, and every unit the parameters use. The reference implementation’s profile is a complete example. - In every
spec.yaml, setschema: spec/0.2. Nothing else changes:satisfieslinks keep their IDs, which are now checked against the profile instead of against a fixed pattern. - In every
parameters.yaml, setschema: parameters/0.2. Units are no longer a fixed list; each one a parameter uses must be declared in the profile. - Leave
scenarios,model,decisionsandquestionsfiles at 0.1. - Run the validator; it reports every file still at an old version and every link or unit the profile does not declare.
Worked example: the template domain
Section titled “Worked example: the template domain”The template models a ledger with accounts and one transfer command, so that every file type
appears once with real content. To start a domain, copy it to specs/<domain>/, replace the
code TPL in every file and every ID, then write the clauses, one scenario per behaviour, the
parameters and the model. Point scope.paths at the code that implements the domain. Every
open choice goes into decisions.yaml with a recommended option; every gap found later goes
into questions.yaml.
The profile comes first, because every satisfies link and every unit below is checked against it.
This one is the reference implementation’s, shortened to the units the template uses:
schema: project/0.2catalogue: source: docs/requirements/catalog.json lists: - key: requirements holds: requirements - key: acceptance_criteria holds: acceptance_criteria - key: architecture_decisions holds: decisions patterns: - name: requirement pattern: "(PH|LH)-[A-Z]{1,4}-\\d{3}" example: PH-WLD-006 - name: acceptance_criterion pattern: "(LA|PA|A09)-\\d{2}" example: LA-17 - name: architecture_decision pattern: "ADR-\\d{3}" example: ADR-004decision_records: # An ADR recorded after the catalogue snapshot still counts as a catalogue entry. - glob: docs/adr/ADR-*.md pattern: "ADR-\\d{3}"units: - name: eur_cent meaning: Money as an integer number of euro cents.schema: spec/0.2domain: TPLtitle: Template domain - a two-account ledgerstatus: draftowner: project-ownersummary: >- A minimal but complete example of a domain spec. It models a ledger with accounts and a transfer command, so every file type of the format appears once with real content. Copy this directory to specs/<domain>/, change the domain code everywhere, and replace the content.scope: satisfies: - PH-WLD-006 - ADR-004 # The code that implements this domain. The template has none, so the glob names a project # that does not exist; a real domain lists its kernel and test projects here. paths: - src/Fm.TemplateLedger/** excludes: - Currency conversion, which a real economy domain decides in its own decisions.yaml.glossary: - term: Posting definition: One balanced booking that debits one account and credits another by the same amount.clauses: - id: TPL-R-001 kind: ubiquitous shall: The ledger shall keep the sum of all account balances equal to the money created minus the money destroyed. satisfies: - PH-WLD-006 - id: TPL-R-002 kind: event when: a TransferMoney command names two existing accounts and a positive amount shall: the ledger shall debit the source and credit the target by exactly that amount in one posting. satisfies: - PH-WLD-006 parameters: - TPL-P-001 - id: TPL-R-003 kind: unwanted if: a transfer would take the source account below its overdraft limit shall: then the ledger shall reject the command with the code overdraft_exceeded and leave both balances unchanged. satisfies: - PH-WLD-006 parameters: - TPL-P-001 decisions: - DEC-TPL-001 - id: TPL-R-004 kind: ubiquitous shall: The ledger shall represent every amount as an integer number of euro cents. satisfies: - ADR-004state_machines: - id: TPL-SM-01 name: posting states: - requested - posted - rejected initial: requested final: - posted - rejected transitions: - from: requested to: posted trigger: TransferMoney guard: within the overdraft limit clause: TPL-R-002 - from: requested to: rejected trigger: TransferMoney guard: beyond the overdraft limit clause: TPL-R-003schema: parameters/0.2domain: TPLparameters: - id: TPL-P-001 name: overdraft_limit description: How far below zero an account may go before transfers out of it are rejected. unit: eur_cent type: int default: 0 min: 0 max: 100000000 status: approved decided_by: DEC-TPL-001schema: scenarios/0.1domain: TPLscenarios: - id: TPL-S-001 title: A transfer moves money and keeps the ledger balanced covers: - TPL-R-001 - TPL-R-002 - TPL-R-004 given: accounts: club: 50000 sponsor: 0 overdraft_limit: 0 when: - command: TransferMoney args: from: club to: sponsor amount: 12500 then: - path: accounts.club op: eq value: 37500 - path: accounts.sponsor op: eq value: 12500 - path: ledger.total op: eq value: 50000 - id: TPL-S-002 title: A transfer beyond the overdraft limit is rejected and changes nothing covers: - TPL-R-003 given: accounts: club: 1000 sponsor: 0 overdraft_limit: 0 when: - command: TransferMoney args: from: club to: sponsor amount: 1001 then: - path: result op: rejected - path: result.code op: eq value: overdraft_exceeded - path: accounts.club op: eq value: 1000schema: model/0.1domain: TPLentities: - name: Account description: A ledger account owned by one party. fields: - name: id type: id<Account> - name: balance type: money - name: overdraft_limit type: money invariants: - A balance never falls below the negative of the account's overdraft limit.commands: - name: TransferMoney description: Move an amount from one account to another in a single posting. fields: - name: from type: id<Account> - name: to type: id<Account> - name: amount type: money emits: - MoneyTransferred clause: TPL-R-002events: - name: MoneyTransferred fields: - name: from type: id<Account> - name: to type: id<Account> - name: amount type: moneyschema: decisions/0.1domain: TPLdecisions: - id: DEC-TPL-001 title: Overdraft question: May an account go below zero, and if so, how far? blocking: true options: - key: no_overdraft title: No overdraft description: Every transfer that would take an account below zero is rejected. - key: limited_overdraft title: Limited overdraft per account description: Each account carries a limit; the default limit is a parameter with an approved range. recommended: limited_overdraft rationale: A limit keeps the rule data-driven and covers both cases, because a limit of zero is the same as no overdraft. references: - PH-WLD-006 status: approved answer: choice: limited_overdraft answered_by: project-owner answered_at: "2026-09-22T00:00:00Z" source: template exampleschema: questions/0.1domain: TPLquestions: - id: Q-TPL-001 question: Does a rejected transfer still produce an event for the audit trail? context: Raised while writing TPL-R-003; the clause says the balances stay unchanged but not whether anything is recorded. clauses: - TPL-R-003 raised_by: spec author raised_at: "2026-09-22T00:00:00Z" status: answered answer: No domain event; the rejection is returned to the caller and logged by the command pipeline.Read together, the example shows every link the gates follow: PH-WLD-006 and ADR-004 resolve
through the profile’s requirement and architecture_decision patterns and the unit eur_cent
through its units list; TPL-R-003 depends on the
parameter TPL-P-001 and the decision DEC-TPL-001; the parameter was approved by that same
decision; TPL-S-002 covers the clause and is the oracle for its test; the state machine names
the clauses behind its transitions; and Q-TPL-001 records a gap that was answered instead of
assumed.