Skip to content

Language Guide

listenrightmeow edited this page Jul 17, 2026 · 2 revisions

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.


1. File anatomy

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.

Lexical rules

  • 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 uuid as any uid, you get a parse error, not a silently-passing test.

The version pragma

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.


2. The header

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.

The eight spec types

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.

The lifecycle

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 to agreed for re-acceptance; regeneration and a green run re-promote it.

3. construct: — build instructions

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.

4. enforce: — behavioral rules

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.


5. contract: — the declared interface

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.

The contract registry (.contract.json)

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.


6. Scenarios

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.

given: — preconditions

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

The trigger

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_COMMAND otherwise).
  • The payload is inline JSON (unquoted keys allowed).
  • (as <actor>) selects an identity from the config's actor registry (UNKNOWN_ACTOR if undeclared). anonymous is 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).


7. predict — the measurement contract

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

The response line

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.

Service lines

Every configured service must appear in every predictionhas [] 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 alongside has.
  • Ordering defaults to the service's consistency model — acid/strong services assert order, eventual services assert as a multiset. Override per line with ordered / unordered.
  • Each record is <Type> with <Schema>, optionally + a value block or equals fixture.

The query guarantee

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.


8. Matchers — value-level assertions

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.

9. Scenario outlines — one scenario, many cases

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 declared rejects ID), and the matching argument.
  • Column headers are the placeholder names; cells are JSON literals.

10. Configuration — feat.config.json

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, against invoke.baseUrl. Deliver-only projects may omit the whole response block.
  • response.actors — named identities with adapter-specific auth material (HTTP: headers; handler: a context object passed to your function). default names the implicit actor; anonymous is reserved and never declared.
  • services — every observable surface predictions may target. consistency controls capture timing: acid (immediate), strong (post-replication), eventual (single shared wait of convergenceTimeout ms, then capture). absenceTimeout optionally shortens the wait for rejection-heavy suites. options is adapter-specific.
  • Adapters are npm packages or local files exporting createAdapter(config) — resolution is anchored at the project root.

11. Every parse error

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.


12. Prescriptions

  1. One spec per feature. A spec is a contract, not a module dump.
  2. Predict rejections generously — they're the cheapest scenarios (zero effects everywhere) and where real systems break. Every rejection needs its rejects justification.
  3. Bind values, don't restate them: @when.email, not a copied string.
  4. Reach for typed wildcards (any uuid, any timestamp) for generated values; reach for goldens when the whole document is the assertion.
  5. Prefer execute over seed — go through the same door production uses. Seed only what has no door.
  6. Cover the boundary with an outline, not with near-duplicate scenarios.
  7. Declare touches honestly. If the implementation needs to touch something outside the boundary, that's a conversation about the spec, not a wider glob.
  8. Don't build from a draft. The agreed flip is the point of the whole exercise.

Clone this wiki locally