Skip to content
Select themeSelect language

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.

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

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 example must 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).

<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.

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.

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.

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.

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; mindefaultmax
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.

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.

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.

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.

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

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 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).

Terminal window
uv run python tools/qa/check_specs.py # the spec gate, as validate all runs it
uv run python tools/qa/check_specs.py --base origin/main # including drift against main
uv run python tools/qa/check_specs.py --write-baseline # after a domain takes on catalogue IDs
uv run python tools/specs/validate.py # every domain, plus the version lock
uv run python tools/specs/validate.py specs/econ # one domain
uv run python tools/specs/validate.py --write-lock # after a schema change
uv run python tools/specs/validate.py --init-lock # only when there is no lock yet

Beyond 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), mindefaultmax, 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.

  1. Add specs/project.yaml if the repository has none: the catalogue source and its lists, one pattern per ID shape that satisfies links use, the decision records if any, and every unit the parameters use. The reference implementation’s profile is a complete example.
  2. In every spec.yaml, set schema: spec/0.2. Nothing else changes: satisfies links keep their IDs, which are now checked against the profile instead of against a fixed pattern.
  3. In every parameters.yaml, set schema: parameters/0.2. Units are no longer a fixed list; each one a parameter uses must be declared in the profile.
  4. Leave scenarios, model, decisions and questions files at 0.1.
  5. Run the validator; it reports every file still at an old version and every link or unit the profile does not declare.

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:

specs/project.yaml
schema: project/0.2
catalogue:
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-004
decision_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.
specs/_template/spec.yaml
schema: spec/0.2
domain: TPL
title: Template domain - a two-account ledger
status: draft
owner: project-owner
summary: >-
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-004
state_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-003
specs/_template/parameters.yaml
schema: parameters/0.2
domain: TPL
parameters:
- 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-001
specs/_template/scenarios/transfers.yaml
schema: scenarios/0.1
domain: TPL
scenarios:
- 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: 1000
specs/_template/model.yaml
schema: model/0.1
domain: TPL
entities:
- 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-002
events:
- name: MoneyTransferred
fields:
- name: from
type: id<Account>
- name: to
type: id<Account>
- name: amount
type: money
specs/_template/decisions.yaml
schema: decisions/0.1
domain: TPL
decisions:
- 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 example
specs/_template/questions.yaml
schema: questions/0.1
domain: TPL
questions:
- 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.