usedesign. Operation Card — SPEC v0.2 (draft) 1 / 20

A format for describing what a system can do

usedesign.

One description per operation. Every screen, contract, test and migration points back to it.

SPEC v0.2 · draft Apache 2.0 YAML front matter + Markdown github.com/nistoc/usedesign
Space next back HomeEnd first / last T theme L language · or swipe ·

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 genres that never agree

Four islands. Each knows one thing well and cannot be asked about anything else.

API specification

OpenAPI, and friends

AnswersWhich endpoints exist

Cannot answerWhich screens use them, what is tested, why it exists

Decision records

ADRs

AnswersWhy a decision was made

Cannot answerWhat implements the decision

Test suite

CI, green or red

AnswersWhat currently passes

Cannot answerWhich behaviour is not covered

Tribal knowledge

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

What that costs

The screen drifts from the API

Nothing in either artifact says which screen depends on which behaviour, so nothing notices when they part ways.

The suite is green while a whole branch is unproven

A test suite reports what it ran. It has no vocabulary for the branch nobody wrote a test for.

A deliberate decision survives only as a code comment

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

We tried to buy this before building it

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.

FamilyExamplesWhat it does wellWhy 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.

Event Storming is the closest neighbour, and it is not a competitor

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

So what is actually missing?

JSON Schema is not a competitor — it is the material

Whatever format you choose, you validate it with JSON Schema. usedesign uses it too.

The gap is not another way to describe an API

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.

Said plainly

usedesign does not replace any of the tools above. It is the missing centre they were all pointing near, but never at.

OpenAPI · AsyncAPI Gherkin · Pact decision records service catalogue C4 · arc42 tracker traceability Operation Card a stable id to point at

Six good tools, all aimed at the same empty spot.

06 — the idea

Describe it once. Let everything point back.

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.

screen · covers_steps contract · openapi test · covers migration · since worker · job_states Operation Card library.loan.checkout

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

Three levels

Humans read the top. Tests and contracts attach to the bottom.

Scenario library.borrowing.member-borrows-a-book

A user goal, told in human terms — “a member borrows a book”.

└─ Operation library.loan.checkout

One atomic action with a stable id. The id never changes — tests and code point at it.

└─ Step s3-member-standing

One stage inside that action, including the failure branches. Step ids are never renumbered or reused.

One file, one operation

The file name equals the operation id. No collections, nothing to merge-conflict over.

No orphan endpoints

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

The axes of life

Stages of life are not a single field. Six different things change at five different speeds, and each is owned by different artifacts.

A
Maturitymaturity
How far the operation is built: conceived → designed → implemented → tested → in production → deprecated
Weekly, as work lands
B
Stepssteps[]
What happens during execution, including the failure branches
Only when behaviour changes
C
Data lifecycledata_transition
What the operation does to the state of the record itself
Almost never — it is the domain model
D
Versionsince
Since which migration or release the operation behaves this way
Every release
E
Async jobasync_execution.job_states
The life of the background work the operation starts
Only for async operations
F
Continuationcontinuation
The operation stopped and cannot proceed until a person decides — nothing to observe, nobody watching moves it
Only for operations that can suspend

Squashing 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

Why the axes must never merge

A test moves two axes at once

A test attaches to a step (axis B) and, by existing, moves maturity (axis A). A screen covers a set of steps.

teststep · axis Bmaturity · axis A

A migration attaches to neither

It belongs to axis D — which is exactly why migrations end up orphaned in every traceability scheme that has only one stage field.

migrationsince · axis D

Axis B is not axis C

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.

Axis E is neither

A job may reach succeeded while the record it produced stays draft.

Every weakening is explained

Any relaxation — no optimistic locking, no audit trail, no owning scenario — carries a rationale. A silent relaxation is a spec bug.

10 — the file

Anatomy of a card

Deliberately boring: YAML front matter for machines, Markdown body for humans. No new language, no compiler, no runtime.

---front matter · for machines
id · title · scenario | serves_step · actorsidentity
maturity · maturity_evidenceaxis A
steps[] — id · text · on_violation · rationale · sourceaxis B
data_transition · mutatesaxis C
since — migration · noteaxis D
async_execution — job_states · terminal · workeraxis E
concurrency · quota · interfaceshow it is reached
data · provenance · reversibility · sensitivitywhat it touches
tests[] · coverage_gaps[]what proves it
---markdown body · for humans
# Check out a copy to a memberwhy it works this way

The file name is the id

examples/library/
  library.loan.checkout.op.md

One file, one operation. No collections.

Claims cite source

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.

Withdrawn decisions get a tombstone

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

The fields that surprised us

Each one exists because writing real cards found somewhere the format had nothing to say.

concurrency — four modes, not a boolean

etag_requiredidempotency_by_header idempotency_by_formulanone_by_design

Real 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 concurrency

A 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 inferred

An 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 codebases

An 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

Traceability, in both directions

library.loan.checkout.op.md · one step, its test, its screen
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]

Forward

operationsteptest+screen

What proves this behaviour, and where is it shown to the user?

And backward

teststepscreen · contract · migration

From a failing test you reach the step, the screen that covers it and the contract that exposes it — without asking anyone.

A gap is a line, not a silence

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

A synchronous write: optimistic locking, a lifecycle transition, an audit trail, and a counterpart that undoes it. The shape everything else varies from.

library.loan.checkout.op.md · two guards that stay separate
- 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 }

Why not one “not allowed” error?

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.

The rest of the card, in one breath

  • 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.
  • The due date is computed at commit from the tier’s loan period, so changing a policy does not rewrite open loans.

14 — example · asynchronous work

library.catalog.import

The response means accepted, not done. Everything interesting happens afterwards, in a worker with a life of its own.

axis E · the job is not the record
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 …
top level · over REST
quota:
  scheme: per_credential_per_minute
  categories:
    import: { bucket: 5, window_min: 1 }
  applies_to: api_key_callers_only
per-transport override · over RPC
interfaces:
  rpc:
    transport: json_rpc
    quota:
      categories: { write: { bucket: 2, window_min: 1 } }
      applies_to: all_rpc_callers

partial is a success

Titles still import, only authority links are missing. Calling it failed would tempt operators to re-run the whole import and create duplicates.

Same operation, two throttles

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.

Retries join, not duplicate

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

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

library.member.purge.op.md
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.
library.member.settlement-check.op.md · the paired helper
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

What irreversible buys

  • Typed confirmation. The screen requires typing the member number.
  • A prior-closure gate. An active member cannot be purged directly.
  • A settlement guard. Open loans, unpaid fines or an active hold refuse the whole operation, with the counters in the error payload.
  • Tests that prove refusals rather than the happy path.

And a receipt that carries nothing

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

Why the helper is not an orphan

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 three checks

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:

  1. No wild endpointsEvery route the code registers is declared by some card. The repository publishes a route inventory — a runtime dump of its own route table — and the checker compares sets. It never parses your framework, which is why routes generated from a template are caught rather than missed.
  2. No unproven stepsEvery step is covered by a test that exists, ran, passed and was not skipped — or appears in 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.
  3. No inflated maturityThree claims of different natures. The 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

How this was hardened

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.

RoundAreas exercisedFields 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

And then the specification was checked against itself

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.

Criterion for v1.0

Not “no more breakage” — untouched areas will always break something — but a round that changes only optional fields, never required ones.

18 — technology

Boring on purpose

YAML front matter + Markdown

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.

OpenAPI is generated from the cards

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.

JSON Schema, draft 2020-12

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.

What a schema cannot express belongs to the checker

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.

Planned CLI in TypeScript / Node

The repository is already set up for Node. Three verbs: validate, gen openapi, check.

schema/operation-card.schema.json · a conditional rule
"$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

Roadmap

  • Specification and schema
    SPEC v0.2 and the JSON Schema for the front matter
  • Worked examples
    Five cards over a fictional library system
  • Conformance corpus; all three checks designed
    24 card cases and 23 repository cases, agreeing on verdicts, codes and warnings. Run for real against an unfamiliar system: 113 routes from its own OpenAPI, 403 tests from an actual run
  • usedesign validate
    Cards against the schema and the cross-card rules — the schema catches 7 of the 10 invalid corpus cases, the rules catch the other 3
  • usedesign check
    The three invariants, holding all 23 repository cases
  • A reference implementation, and CI
    TypeScript on three Node versions, the Python prototype beside it, and a job that insists the rotten fixture still fails
  • Published, with build provenance
    npx usedesign — nothing to install. Running in a real project's CI, where it collapsed three setup steps into one line
  • usedesign gen openapi
    Derive an API contract from the cards. Not started

The checker was the point, and it exists

A 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

SPEC.md — the format and why each field exists schema/ — JSON Schema examples/ — five cards Apache 2.0

One description per operation. Every screen, contract, test and migration points back to it.