-
Notifications
You must be signed in to change notification settings - Fork 0
Language Guide
The complete, prescriptive guide to .feat. Every construct, every rule, every error. The
machine-checked reference is docs/grammar-reference.md in the repository; the corpus/
directory holds nine complete exemplars, each with its expected compiler output.
A .feat file is an execution specification: build instructions for whoever implements the
feature, plus quantified predictions of everything observable it will do. The compiler turns the
predictions into a complete test suite — deterministically, with no interpretation step.
Every spec has exactly this shape, in this order:
feat 1.0 ← version pragma (required, first)
spec <ID> "<name>" ← identity (required)
context <Name>
aggregate <Name>
type <spec-type>
status <lifecycle>
construct: ← build instructions (required, ≥1 directive)
enforce: ← behavioral rules (required, ≥1 directive)
contract: ← declared interface (required, ≥1 entry)
scenario "<name>": ← test scenarios (required, ≥1)
There are no escape hatches. A file missing any required section fails with
MISSING_MINIMUM. This is deliberate: a spec that can't state its interface and at least one
measurable scenario isn't a specification yet.
- Indentation defines blocks (like Python or YAML). Use spaces; a tab in leading whitespace is a parse error.
-
Comments:
#to end of line, on their own lines. -
Multi-line values: any statement that opens
{or[continues until brackets balance, so payloads and record lists may span lines. - Inside compiler-zone blocks, every bare word is either a reserved keyword or an identifier
validated against a declared set — nothing is prose. If you typo
any uuidasany uid, you get a parse error, not a silently-passing test.
feat 1.0
Required as the first non-comment line. It names the language version, not the tool version.
Missing → MISSING_PRAGMA. A version this toolchain can't handle → UNSUPPORTED_VERSION.
Minor versions only add syntax; majors may break it.
| Field | Form | Rules |
|---|---|---|
spec |
spec SPEC-USR-001 "CreateUser" |
ID convention: SPEC-<CONTEXT>-<NNN>. The quoted name is the human title. |
context |
context Users |
The bounded context this feature belongs to. |
aggregate |
aggregate User |
The entity/aggregate the feature operates on. |
type |
type command |
One of eight — see below. The type determines the trigger discipline. |
status |
status agreed |
Lifecycle marker — see below. |
| Type | Meaning | Trigger |
|---|---|---|
command |
An operation that changes state | when: |
query |
A pure read — cannot express writes (see §7) | when: |
policy |
Reacts to an event by acting | deliver |
projection |
Builds a read model from events | deliver |
saga |
Reacts to an event sequence |
deliver (multiple, ordered) |
infrastructure |
Tooling/platform operations | when: |
integration |
Cross-system operations | when: |
scaffold |
Structure-producing operations | when: |
Using the wrong trigger form for the type is TRIGGER_MISMATCH.
draft → agreed → built → verified
-
draft— being written. Not buildable. -
agreed— the contract moment: author and builder accept this is what will be built. Only agreed specs may be implemented. -
built/verified— flipped by tooling when the generated suite passes (never set these by hand). -
Editing an
agreed+ spec demotes it toagreedfor re-acceptance; regeneration and a green run re-promote it.
Freeform natural language, plus six recognized keywords. Write instructions the way you'd brief an engineer; the recognized lines add machine-readable structure.
| Keyword | Form | Prescription |
|---|---|---|
handler at |
handler at src/create-user.ts |
Required for command, query, policy, projection, and saga specs. Where the entry point lives. |
touches |
touches src/users/** |
Always required, at least one. The change boundary: the implementation may create or modify only matching paths, and the handler path must fall inside one. Declare your footprint before you build — the build-time mirror of prediction inversion. |
imports … from |
imports UserRepository from src/repos/user |
Declares a dependency the implementation should use. |
register … in |
register mutation createUser in src/resolvers.ts |
Declares a registration point (router, resolver map, DI container). |
emit to |
emit to users-{userId} |
Declares the target stream/topic pattern for emitted events. |
needs |
needs SPEC-USR-001 |
Declares a dependency on another spec. Builds happen in dependency order; cycles are an audit error. |
| (anything else) | Never call the payment provider synchronously. |
Captured verbatim as a directive. Constraints, conventions, warnings — all valid. |
Freeform, with one structured keyword:
enforce:
validate input against CreateUserInput
check email uniqueness before insert
rejects DUPLICATE_EMAIL when a user with this email already exists
rejects <ID> when <reason> justifies a business-rule rejection. The <ID> is checked both
ways: every rejection a scenario predicts must have a rejects line, and every rejects line
must be predicted by at least one scenario (feat audit enforces this). The reason stays natural
language. Rejection IDs are SCREAMING_SNAKE_CASE by convention.
predict error <CODE> (system failures) is exempt from this pairing — errors aren't business
rules.
Every schema a scenario mentions must be declared here. Referencing an undeclared schema is
UNDECLARED_SCHEMA at parse time — type safety for specs. Six roles:
| Role | Declares | Example |
|---|---|---|
input |
The trigger payload's shape | input CreateUserInput |
response |
A response body shape | response UserResponse |
event |
An emitted event's shape | event UserCreatedEvent |
record |
Any other service-record shape (DB rows, seeded state, read models) | record UserRow |
error |
An error body shape | error ErrorResponse |
stream |
A stream/topic name pattern | stream "users-{userId}" |
Declare only the roles you use. from "<path>" is optional — omitted, the name resolves in the
spec's paired registry; present, it names an external file.
Each spec pairs with a .contract.json of the same base name. It's a plain JSON Schema registry:
{
"$schema": "https://feat.dev/schemas/contract.json",
"specId": "SPEC-USR-001",
"schemas": {
"CreateUserInput": { "type": "object", "required": ["email", "name"], "properties": {} },
"ErrorResponse": { "$ref": "../contracts/error-response.json" }
}
}Shapes live here; behavior lives in the spec. Share schemas across specs with $ref into a
common directory — no special syntax, just JSON Schema doing its job. Resolution happens at
compile time; generated tests carry their schemas inlined.
scenario "rejects duplicate email":
given:
execute CreateUser { email: "a@ex.com", name: "Alice" }
when: CreateUser { email: "a@ex.com", name: "Bob" }
predict rejection DUPLICATE_EMAIL:
response 409 ErrorResponse { code: "DUPLICATE_EMAIL" }
database has []
Scenario names must be unique within a spec (DUPLICATE_SCENARIO_NAME otherwise) — they are
the failure anchors: every test failure points to
SPEC-USR-001 › "rejects duplicate email" › response › code.
Four kinds of lines, freely mixed. Everything in given: happens before measurement begins —
precondition effects are never captured, never diffed.
| Line | Form | Use when |
|---|---|---|
| Freeform | The tenant is on the starter plan. |
Human context. Opaque. |
execute |
execute CreateUser { … } — optionally execute (as admin) …
|
The state is reachable through a real command. Prefer this: it exercises the same door production uses. |
seed |
seed database [ UserRow with UserRow { …full literal values… } ] |
The state has no public command (a suspended user, historical data). Values must be complete literals — seeds are writes, not assertions. |
seed … from |
seed eventStore from "fixtures/fifty-flows.json" |
Bulk state. The fixture is a JSON array of records — data, not code. |
clock at |
clock at "2026-07-16T00:00:00Z" |
Time-dependent behavior. Freezes the scenario clock (direct-handler deployments; remote services need their own time hooks). |
Command-shaped types use when: — exactly one per scenario:
when: CreateUser { email: "a@ex.com", name: "Alice" }
when (as admin): SuspendUser { email: "a@ex.com" }
when (as anonymous): SuspendUser { email: "a@ex.com" }
- The command name must be routed in the config (
UNKNOWN_COMMANDotherwise). - The payload is inline JSON (unquoted keys allowed).
-
(as <actor>)selects an identity from the config's actor registry (UNKNOWN_ACTORif undeclared).anonymousis reserved: unauthenticated. No clause = the configured default.
Event-shaped types use deliver — one or more, in order:
deliver OrderPublished { orderId: "…" } to eventBroker
deliver OrderPriced { orderId: "…", total: 129.5 } to eventBroker
Multiple lines form the sequence (this is how sagas are specified). Delivered events are the
input — they're excluded from capture, so eventBroker has [] afterward means "the feature
emitted nothing new." Deliver-triggered scenarios have no response line and cannot use
predict rejection (business rejections belong to commands).
Three prediction types. Choose by outcome:
| Form | Meaning | What gets asserted |
|---|---|---|
predict success: |
The operation succeeds | The response + the exact expected effects on every service |
predict rejection <ID>: |
A business rule refuses | The error response + zero effects on every service — refusals leave no trace |
predict error <CODE>: |
A system-level failure | The error response + zero or compensating effects |
response 201 UserResponse { id: any uuid, email: @when.email }
response OK ParseResult equals fixture "expected/parse-result.json"
response 409 ErrorResponse
response <status> <Schema> then optionally one of: a value block { … } or a golden
equals fixture "<path>". Status is an HTTP code for HTTP deployments or a string (OK/ERR)
for direct handler invocation. The body always validates against the schema; the value block or
golden adds value-level assertions on top.
Every configured service must appear in every prediction — has [] is the explicit
"nothing happens here"; omission is INCOMPLETE_PREDICTION. Ambiguity is not allowed to hide in
what you didn't say.
eventStore has [ FlowCreated with FlowCreatedEvent { flowId: any uuid } ]
projectionStore has []
outbox has ordered [ A with ASchema, B with BSchema ]
eventBroker has unordered [ B with BSchema, A with ASchema ]
projectionStore contains [ FlowView with FlowViewSchema equals fixture "expected/view.json" ]
-
has [ … ]— exactly these records written during the window. Extra records fail (unpredicted); absent records fail (missing). -
contains [ … ]— the service's resulting state (read back after the window), for projections and post-cascade assertions. May appear alongsidehas. -
Ordering defaults to the service's consistency model —
acid/strongservices assert order,eventualservices assert as a multiset. Override per line withordered/unordered. - Each record is
<Type> with <Schema>, optionally + a value block orequals fixture.
type query specs cannot write a has [ … ] line at all — attempting it is
QUERY_SIDE_EFFECT at parse time. Queries predict a response (and optionally contains); zero
side effects is implicit and grammatically unviolable. If your "query" writes an audit record,
it's a command — model it as one.
Value blocks assert specific fields on top of schema validation. They are partial: only
the fields you list are checked (shape completeness is the schema's job via required /
additionalProperties).
| Matcher | Example | Asserts |
|---|---|---|
| Literal |
status: "active", total: 129.5, deleted: false, ref: null
|
Exact equality |
@when.<path> |
email: @when.email |
Equals the value sent in the trigger payload. Prefer this over restating literals — bound values can't drift. |
@deliver.<path> / @deliver[i].<path>
|
orderId: @deliver[0].orderId |
Equals a delivered event's value (index required for sequences) |
any |
metadata: any |
Present, any value |
any uuid · any timestamp · any string · any number · any boolean
|
id: any uuid |
Present + format/type check — for generated values you can't know in advance |
matching "<regex>" |
hash: matching "^sha256:" |
String matches the pattern |
absent |
deletedAt: absent |
The field must not exist — stronger than omission |
| Nested block | header: { specId: @when.specId } |
Descend and assert inside objects |
equals fixture "<path>" |
(record/response position) | Deep structural equality against a JSON fixture (key order ignored, array order significant). Mutually exclusive with a value block. The fixture is validated against the position's schema at compile time. Paths resolve relative to the spec file. |
For boundary and equivalence testing, write the scenario once and supply rows:
scenario outline "unknown users are rejected":
when (as admin): SuspendUser { email: <email> }
predict rejection <code>:
response 404 ErrorResponse { code: <code>, message: matching <msg> }
database has []
examples:
| email | code | msg |
| "missing@example.com" | "UNKNOWN_USER" | "missing" |
| "ghost@example.com" | "UNKNOWN_USER" | "missing" |
- Each row derives one test case; failures anchor as
› "scenario name" › row[2]. -
<placeholder>is valid in: trigger payloads, value blocks, the rejection-ID position (every row value must be a declaredrejectsID), and thematchingargument. - Column headers are the placeholder names; cells are JSON literals.
The config declares the world specs run against. It is the source of three of the four closed reference spaces (unknown names are parse-time errors that list the valid options):
{
"featVersion": "1.0",
"schemas": { "adapter": "@mmmnt/feat-schema-json" },
"response": {
"adapter": "@mmmnt/feat-adapter-handler",
"invoke": {},
"commands": {
"CreateUser": { "module": "src/create-user.ts", "export": "createUser" },
"SuspendUser": { "module": "src/suspend-user.ts", "export": "suspendUser" }
},
"actors": {
"default": "admin",
"admin": { "context": { "role": "admin" } }
}
},
"services": {
"database": {
"adapter": "@mmmnt/feat-adapter-fs",
"consistency": "acid",
"options": { "scope": "src" }
},
"eventStore": {
"adapter": "your-event-store-adapter",
"consistency": "eventual",
"convergenceTimeout": 5000
}
},
"specs": { "dir": "specs", "pattern": "**/*.feat",
"contractPattern": "**/*.contract.json", "outputPattern": "{name}.test.ts" },
"report": { "format": ["console", "junit"], "junitOutput": "reports/feat-junit.xml" }
}-
response.commands— every command specs invoke, mapped to an adapter-specific route. Handler adapter:{ module, export }. HTTP adapter:{ method, path }with{param}substitution from the payload, againstinvoke.baseUrl. Deliver-only projects may omit the wholeresponseblock. -
response.actors— named identities with adapter-specific auth material (HTTP: headers; handler: a context object passed to your function).defaultnames the implicit actor;anonymousis reserved and never declared. -
services— every observable surface predictions may target.consistencycontrols capture timing:acid(immediate),strong(post-replication),eventual(single shared wait ofconvergenceTimeoutms, then capture).absenceTimeoutoptionally shortens the wait for rejection-heavy suites.optionsis adapter-specific. - Adapters are npm packages or local files exporting
createAdapter(config)— resolution is anchored at the project root.
| Code | Meaning |
|---|---|
MISSING_PRAGMA |
First non-comment line isn't feat <major>.<minor>
|
UNSUPPORTED_VERSION |
The pragma names a version this toolchain can't handle |
MALFORMED_SYNTAX |
A compiler-zone block violates the grammar |
UNKNOWN_SERVICE_KEY |
A prediction/seed/deliver targets a service not in the config |
UNKNOWN_COMMAND |
A when:/execute names a command not in response.commands
|
UNDECLARED_SCHEMA |
A scenario references a schema missing from contract:
|
UNKNOWN_ACTOR |
An (as …) names an actor not in response.actors
|
DUPLICATE_SCENARIO_NAME |
Two scenarios share a name |
TRIGGER_MISMATCH |
Wrong trigger form for the spec type |
QUERY_SIDE_EFFECT |
A query predicts a write |
INCOMPLETE_PREDICTION |
A prediction omits a configured service |
MISSING_MINIMUM |
A required section is missing |
Every error carries the file, line, and a hint listing the valid alternatives.
- One spec per feature. A spec is a contract, not a module dump.
-
Predict rejections generously — they're the cheapest scenarios (zero effects everywhere)
and where real systems break. Every rejection needs its
rejectsjustification. -
Bind values, don't restate them:
@when.email, not a copied string. -
Reach for typed wildcards (
any uuid,any timestamp) for generated values; reach for goldens when the whole document is the assertion. -
Prefer
executeoverseed— go through the same door production uses. Seed only what has no door. - Cover the boundary with an outline, not with near-duplicate scenarios.
-
Declare
toucheshonestly. If the implementation needs to touch something outside the boundary, that's a conversation about the spec, not a wider glob. -
Don't build from a
draft. Theagreedflip is the point of the whole exercise.