API specification
OpenAPI, and friends
AnswersWhich endpoints exist
Cannot answerWhich screens use them, what is tested, why it exists
A format for describing what a system can do
One description per operation. Every screen, contract, test and migration points back to it.
01 — the question
“What can this system actually do, and what state is each of those things in?”
Ask a mature system that question and there is usually no answer.
Not because nobody knows — because the knowledge is split across genres that never agree with each other. Each one is complete inside its own boundary and silent outside it.
02 — where the answer is scattered
Four islands. Each knows one thing well and cannot be asked about anything else.
OpenAPI, and friends
AnswersWhich endpoints exist
Cannot answerWhich screens use them, what is tested, why it exists
ADRs
AnswersWhy a decision was made
Cannot answerWhat implements the decision
CI, green or red
AnswersWhat currently passes
Cannot answerWhich behaviour is not covered
and code comments
AnswersEverything else
Cannot answerAnything, to anyone who has not read that file
None of them is a catalogue of operations. And none of them lets an artifact — a test, a screen, a contract, a migration — declare what it implements and which stage of life it belongs to.
03 — the bill
Nothing in either artifact says which screen depends on which behaviour, so nothing notices when they part ways.
A test suite reports what it ran. It has no vocabulary for the branch nobody wrote a test for.
One person will ever read it — the one who is already in that file, and who therefore did not need it.
An Operation Card is the missing unit: the place where those four genres meet and where an artifact can point at what it implements.
04 — prior art
A description of an operation has to serve six consumers at once — the UI, the data model, the external contract, the service-to-service contract, the tests, and the database migrations. Here is how much of that six each existing family already covers.
| Family | Examples | What it does well | Why it is not enough here |
|---|---|---|---|
| API contracts | OpenAPI, AsyncAPI, gRPC / protobuf, Smithy, TypeSpec | The interface, precisely — plus strong code generation | Describes endpoints, not operations. One operation often spans several endpoints; one endpoint serves several operations. Nothing about screens, tests, migrations or maturity |
| Behaviour & contract testing | Gherkin / Cucumber, Pact | Executable behaviour; a test that states what it checks | No data model, no contracts, no migrations. Pact is a check, not a description — it verifies agreement, it does not say what exists |
| Service catalogues | Backstage Software Catalog | Ownership, discovery, a real lifecycle field | Catalogues components, not operations, and lifecycle is a single field. It stops one level above the thing we needed to describe |
| Architecture notations | C4 / Structurizr, arc42, ArchiMate | Structure and boundaries; ArchiMate can express almost anything | Describes the shape of the system, not its operations. ArchiMate is expressive but GUI-bound, hard to review in diffs, and generates nothing |
| Decision records | ADR | Why a decision was made — irreplaceable | A decision is not an operation. Nothing points from an ADR to the code that implements it |
| Requirements traceability | Jira + Xray / Zephyr, DOORS | The closest existing thing: real test-to-requirement links | Lives in a tracker, not the repository — outside diffs, outside review, outside the pull request. The requirement itself is prose |
| Formal methods | TLA+, Alloy | Proves properties of a model | Proves the model, and says nothing about its implementations. The wrong cost for CRUD-shaped systems |
| Collaborative modelling | Event Storming, Domain Storytelling | The best way there is to agree what the operations are — and it takes the same unit we do: the thing that happens | Produces a wall of stickies, then a photograph in a wiki. Truth is the agreement in the room, and nothing afterwards compares it with the running system |
Each family closes one or two of the six consumers. None of them closes the link between them.
It is the half that comes before — a room agreeing on what happens. This format is the half that comes after: the same agreement written down so that it cannot go stale quietly.
Three differences do the work. When: a workshop runs before the code, a card lives against it. What counts as true: there, the nod of everyone present; here, what the system states about itself — its own route list, its own test report, the keys of its own live database. What survives: a sticky wall ages silently in a wiki; a card that has drifted is printed by the next build.
They chain without strain: the workshop yields the vocabulary and the boundaries, the cards turn that result into files that cannot lie for long. A sticky reading “request rejected” becomes an outcome named rejected — and from then on, if the code stops producing it or the screen stops showing it, somebody learns the same day rather than the following year.
05 — the gap
Whatever format you choose, you validate it with JSON Schema. usedesign uses it too.
It is a unit with a stable identifier that the other artifacts can point at. Once that unit exists, everything else keeps doing its own job: OpenAPI is generated from it, Gherkin scenarios attach to its steps, Pact verifies its contracts, ADRs justify it, a service catalogue links to it.
usedesign does not replace any of the tools above. It is the missing centre they were all pointing near, but never at.
Six good tools, all aimed at the same empty spot.
06 — the idea
One operation, one file that both a human and a machine can read. Tests attach to steps. Screens declare which steps they cover. Migrations attach to the version axis. Nothing is left to be inferred.
The question “what proves this behaviour, and where is it shown to the user?” now has an answer that fits on one screen.
07 — structure
Humans read the top. Tests and contracts attach to the bottom.
A user goal, told in human terms — “a member borrows a book”.
One atomic action with a stable id. The id never changes — tests and code point at it.
One stage inside that action, including the failure branches. Step ids are never renumbered or reused.
The file name equals the operation id. No collections, nothing to merge-conflict over.
An operation that belongs to no scenario must declare whom it serves (serves_step) — otherwise nobody knows who calls it, and it is probably forgotten.
08 — the heart of the format
Stages of life are not a single field. Six different things change at five different speeds, and each is owned by different artifacts.
maturitysteps[]data_transitionsinceasync_execution.job_statescontinuationSquashing six clocks into one stage field is why traceability schemes rot. F arrived in round 7 — the format was published with five.
09 — the classic mistakes
A test attaches to a step (axis B) and, by existing, moves maturity (axis A). A screen covers a set of steps.
It belongs to axis D — which is exactly why migrations end up orphaned in every traceability scheme that has only one stage field.
Steps are states of executing the operation. The data transition is the state of the record. A publish operation has its own steps and moves a record from draft to published. Conflating the two is the second classic mistake.
A job may reach succeeded while the record it produced stays draft.
Any relaxation — no optimistic locking, no audit trail, no owning scenario — carries a rationale. A silent relaxation is a spec bug.
10 — the file
Deliberately boring: YAML front matter for machines, Markdown body for humans. No new language, no compiler, no runtime.
examples/library/
library.loan.checkout.op.md
One file, one operation. No collections.
Anything asserted about the implementation carries source: path:line. An unsourced claim is treated as unverified — and cards are written from measurement, not from memory.
On the line, not a deletion. id and step ids are immutable; renaming means a new operation plus deprecated on the old one.
11 — fields
Each one exists because writing real cards found somewhere the format had nothing to say.
concurrency — four modes, not a booleanReal systems mix all four, and the reasoning usually survives only in a code comment next to one of them. Every mode except the strictest must carry a rationale.
quota — neither a role nor concurrencyA role answers who may. Concurrency answers what happens on collision. Quota answers how often. For an external consumer, the difference between “10 writes per minute” and “unlimited” is part of the promised contract — and it is frequently invisible because it depends on how the caller authenticated.
reversibility — required, never inferredAn irreversible operation needs different UX (confirmation), a different test (the guard) and often a different permission. Without an explicit field, telling a hard delete from a soft retire means reading the implementation.
sensitivity — one line, three codebasesAn operation that returns a secret once states one rule for three consumers at the same time: the UI must not show it twice, the gateway must not log it, the test must not print it.
async_execution — when the response does not mean “done”Job states, terminal states, the worker, how to observe it, how to cancel it, and what happens when a service it depends on fails. Axis E has its own life: a job can succeed while everything it produced is still a draft.
12 — the payoff
steps:
- id: s3-member-standing
text: Member has no blocking holds — unpaid fines
above the threshold, or a suspended card
on_violation: { error: member_blocked, http: 403 }
tests:
- { id: CheckoutTests.blocked_member_cannot_borrow,
covers: s3-member-standing, level: integration }
interfaces:
ui:
screen: CirculationDesk
covers_steps: [s1-copy-available, s3-member-standing,
s4-loan-limit, s5-commit]
What proves this behaviour, and where is it shown to the user?
From a failing test you reach the step, the screen that covers it and the contract that exposes it — without asking anyone.
coverage_gaps:
- step: s6-audit
gap: no test for the rollback path
when the audit write fails
coverage_gaps is a tool, not an admission. A missing line is invisible; a present line is reviewable.
ui.covers_steps is the mechanism that makes UI/API drift visible: if a screen claims to cover a step that does not exist, the checker fails.
13 — example · the ordinary case
library.loan.checkoutA synchronous write: optimistic locking, a lifecycle transition, an audit trail, and a counterpart that undoes it. The shape everything else varies from.
- id: s3-member-standing
text: Member has no blocking holds — unpaid fines
above the threshold, or a suspended card
on_violation: { error: member_blocked, http: 403 }
rationale: >
Checked before the loan limit so the member sees the
real reason. Hitting the limit is recoverable by
returning a book; a suspension is not …
- id: s4-loan-limit
text: Member is below the concurrent loan limit for
their membership tier
on_violation: { error: loan_limit_reached, http: 409 }
Because the two answer different questions. Standing asks may this person borrow at all; limit asks may they borrow one more. The first sends the member to the front desk; the second is fixed by returning something. A single merged error would send half of the people to the wrong desk.
concurrency: etag_required — a stale revision is a 412, not a lost write.data_transition: { from: available, to: on_loan } — axis C, stated once.since: { migration: "014" } — axis D, the place migrations stop being orphans.reversible_via: library.loan.return — the undo has a name.14 — example · asynchronous work
library.catalog.importThe response means accepted, not done. Everything interesting happens afterwards, in a worker with a life of its own.
async_execution:
job_states: [queued, running, succeeded, partial,
failed, cancel_requested, cancelled]
terminal: [succeeded, partial, failed, cancelled]
worker: src/workers/ImportWorker.ts
observe_via: GET /v1/imports/{jobId}
s2s_dependency:
service: MetadataEnrichment
failure_mode: >
Job ends as `partial`, not `failed` — titles are
imported without authority links …
quota:
scheme: per_credential_per_minute
categories:
import: { bucket: 5, window_min: 1 }
applies_to: api_key_callers_only
interfaces:
rpc:
transport: json_rpc
quota:
categories: { write: { bucket: 2, window_min: 1 } }
applies_to: all_rpc_callers
partial is a successTitles still import, only authority links are missing. Calling it failed would tempt operators to re-run the whole import and create duplicates.
Five imports a minute over REST; a bucket of two over RPC. The kind of difference that lives in nobody’s documentation and surprises the first external integrator.
idempotency_by_formula: the server derives the key from the payload, because batch scripts cannot be relied upon to send a stable one.
15 — example · irreversible, and its helper
library.member.purgeThe card that justifies making reversibility a required field. Every other write here can be undone. This one cannot — and that single fact changes four things at once.
reversibility: irreversible
steps:
- id: s2-closed
text: Membership is already closed — an active
member cannot be purged directly
on_violation: { error: member_still_active, http: 409 }
rationale: >
Forcing closure first makes erasure a two-person,
two-step act. A single call that both closes and
erases would turn a misclick into an
unrecoverable loss.
serves_step:
operation: library.member.purge
step: s3-settled
purpose: >
Let the operator see the obstacle before attempting
an irreversible action, instead of discovering it as
a rejection. The point is not to report the conflict
afterwards — it is to prevent the attempt.
data_transition: null
provenance: none
irreversible buysAn erasure that leaves no trace is indistinguishable from data loss. The receipt proves the erasure happened while carrying nothing the erasure was meant to destroy.
Nobody wakes up wanting to check settlement, so it has no scenario — it declares serves_step instead. provenance: none is stated explicitly, which keeps the checker from demanding an audit test that should not exist.
16 — enforcement
The format only pays for itself with an automated checker. Three invariants, each measured against something the repository states rather than something the checker guesses:
coverage_gaps. Read from a JUnit report of the run being checked. A skipped test is an error, and a louder one than an absent test: it keeps its name in every report.implemented path must exist; tested must have a named test that passes; deployed cannot be verified by anyone — so it expires instead.State it plainly: without check 3 in particular, cards drift into aspiration within a couple of months. A catalogue without a checker becomes aspiration.
A card disagreeing with the code is a defect of the card until proven otherwise — even in a specification-first workflow, where the intended difference must be marked explicitly.
17 — maturity of the spec itself
v0.2 was reached by writing cards for eight real operations of a production system and recording where the format broke. It broke in seven places, over two rounds, and was extended each time.
| Round | Areas exercised | Fields the format was missing |
|---|---|---|
| 1 | CRUD, async ingestion, curation UI, read-only helper | async_execution, concurrency, serves_step |
| 2 | RPC protocol, security administration, cross-system boundary | transport, quota, reversibility, sensitivity, consumer_boundary, contract_version |
| 3–5 | Building the three checks — using the cards instead of writing more of them | None. Wording, and two fields widened to accept what cards already contained |
| 6 | A second implementation, from the specification and schema — not from the first one | None. One defect, and it was in the port: CRLF cards |
| 7 | Four untouched areas: a question in human language, an undo, paged search, attachments | Five. Including one that made every read-only card say something false |
| 8—9 | Repairing round 7, then pointing the tool at a system it had never seen | Three, plus a normalisation bug that made the checker silently wrong — and a rule of my own fitted to its single example |
Before publishing, this document was validated against its own schema and examples — the exercise it asks of everyone else. It failed in seven places of a different kind: a field present in the schema and in an example but described nowhere; a rule stated in prose that the schema did not enforce; one term spelled two ways across two files. All seven are fixed. That is the argument for having a checker at all.
Not “no more breakage” — untouched areas will always break something — but a round that changes only optional fields, never required ones.
18 — technology
No new language, no compiler, no runtime, no lock-in. Every editor already renders it; every CI already has a YAML parser.
The prior art was studied before any of this was written, and the choice follows from it: a new language would add a seventh format to the six that already exist, instead of tying them together.
This is not a replacement for an API specification and not a competitor to one. An API spec describes endpoints; a card describes an operation, which may span several endpoints, a screen, a background worker and a migration. The contract is derived from the card — so the two cannot drift.
The front matter validates against a schema that also expresses conditional rules — claiming tested requires at least one recorded test; a write with no lifecycle transition must say what it changes.
Cross-card rules: step references that must resolve, coverage of every step, per-transport overrides, routes in the code with no card. A schema validates one file; these span the catalogue.
The repository is already set up for Node. Three verbs: validate, gen openapi, check.
"$comment": "Claiming 'tested' or beyond requires at
least one recorded test and a tested note.",
"if": {
"properties": {
"maturity": { "enum": ["tested", "in_production"] }
},
"required": ["maturity"]
},
"then": {
"properties": {
"tests": { "minItems": 1 },
"maturity_evidence": { "required": ["tested"] }
},
"required": ["tests"]
}
19 — where this goes
usedesign validateusedesign checknpx usedesign — nothing to install. Running in a real project's CI, where it collapsed three setup steps into one lineusedesign gen openapiA catalogue without one becomes aspiration within months. There are now two implementations, on two runtimes, and CI diffs what they report about the same repository — because two tools agreeing that something is broken while disagreeing about what is broken is how dialects start. The schema had never been run against the corpus until the second implementation existed. It agreed with it completely.
Read it, break it, tell us where
github.com/nistoc/usedesign
One description per operation. Every screen, contract, test and migration points back to it.