requirement.functional / requirement.architectural — record what your system is
supposed to do, as metadata, checked by meta verify.
Status: registered vocabulary in all five ports (TypeScript, Java, C#, Python, Kotlin).
The meta verify gate is TypeScript-CLI-only; the other ports load and validate.
Entirely opt-in. A model with no requirement.* nodes gets no diagnostics, generates
nothing, and reads nothing — no codegen, migrate or runtime path touches the type. You opt in
by declaring, not by configuring.
Your model says what the system is. It does not say what any of it is for, which of its rules are deliberate, or what someone decided and chose not to close. A ledger of requirements says those things next to the entities they govern, in the same metadata the loader already validates — so the claim and the thing claimed cannot drift apart silently.
Concretely, it answers questions the model alone cannot: which capability does this entity
serve? Is this rule universal, or does it have known exceptions somebody accepted? What
did we say we would build and have not? meta verify then checks the answers are still
true — that every claim resolves, that a live policy is applied to something, that an
entity nobody claimed gets flagged.
A requirement is PRESCRIPTIVE. It states what should be true; it is never a journal of
what happened. A capability that no longer applies is deleted, not annotated as retired —
the record of it having existed belongs to version control, and anything worth carrying
forward belongs in notes on the entries that survive.
Requirements live beside the entities they describe (by default in metaobjects/):
Hierarchy is nesting — an L1 solution contains L2 segments contain L3 services. There is
no id and no parent: regrouping moves a subtree.
Five levels, and links only at the bottom two. L1 solution, L2 segment, L3 service,
L4 object, L5 member. @implementedBy is legal at L4 and L5 only — L1–L3 are
organisational and never reference the model.
What L4 and L5 may name. L4 names a declared top-level node: an object.* or a
template.*. A declared prompt is a model node realising a capability in the same sense
an entity is — and it is the one most in need of a status, because a retired prompt leaves
no table behind to notice. L5 names a member of one: a field, a view, a validator, an
identity, or a template's child.
{ "requirement.functional": {
"name": "sceneBrief", "@level": 4, "@status": "live",
"@statement": "The game master is told what the party can currently see.",
"@counterexample": "A scene narrated from world state the party has no way to know.",
"@implementedBy": ["acme::play::sceneBrief"] // a template.prompt
}}L1–L3 are levels of abstraction and ownership in the problem domain — whose need is this, and at what altitude — and are never a directory, package, deployable or module. Binding to technical constructs happens only at L4 and L5, which is the allocation step. The test to apply to every node: if a refactor that changes no behaviour would force this node to move, its level is wrong. Splitting a service, merging two packages or renaming a module must not touch the tree.
Every requirement states its counterexample. "Every entity has a uuid primary key" is violable — point at one with a composite key. "Things are persisted" is not, and is a description rather than a requirement. If you cannot say what breaking it looks like, delete it.
A requirement carries four prose slots, and they overlap badly if you do not decide the
split up front. @statement already occupies the "what is this" role that a common
description usually holds, so the other three narrow around it:
| Slot | Holds | Test |
|---|---|---|
title |
A short noun-phrase label. name is an identifier; this is what an index shows. Not one of the four — it names the entry rather than saying anything about it. |
Is it a phrase, not a sentence? |
@statement |
The claim, in one sentence. This IS the description of what the requirement is. | Could someone disagree with it? |
@counterexample |
The counterexample that makes the claim checkable. | Can you point at the thing that breaks it? |
description |
The scope: what the claim covers, what it deliberately does not, and which sibling entry owns the rest. | Does it help someone decide whether their new field falls under this? |
notes |
The evidence: how you know the @status is true — file/line citations, enum vocabularies, the control you ran to prove an absence was real. |
Would this sentence have to change if the code changed but the model did not? |
Do not use summary on a requirement. It is legal — it is a common attr registered on
every node — but @statement is already the required one-line sentence, so summary can only
repeat it, and no requirement surface reads it. verify warns
(WARN_REQUIREMENT_INERT_DOC_SLOT). title is the opposite case: it is chartered for a
requirement by name (spec/capability-ledger.md, the requirement attribute table) precisely
because a requirement's name is an identifier and its address renders as a dotted camelCase
path — a label is what an index wants.
Do not put a catalogue or ticket id in title. A title is a noun phrase and an id is not a
name, so title: "FR-467 — Order recording" is two things in one slot. Split it: the id goes in
@trackedBy, which is read and is the slot for exactly that, and the noun phrase stays as the
title. verify warns (WARN_REQUIREMENT_TITLE_IS_AN_ID).
title renders on the generated requirements page, in the heading, after the dotted path:
## checkout.payment — Payment capture. The path stays there because it is the address every
other surface prints — the TOON artifact's first column, the backlink on a claimed entity's
page, and every verify diagnostic — so a reader arriving from any of them can search for it.
A requirement with no title heads by its path alone.
notes is unrendered on purpose: it is chartered internal-only, so being absent from every
published surface is the point of it.
Two failure modes are worth naming because both look like diligence:
- A
descriptionthat paraphrases the@statement. Pure padding, and it makes every later reader trust the ledger less. If the scope is genuinely obvious from the statement, leavedescriptionoff — it is optional. - A
descriptionthat narrates the evidence. The tell is a fact you had to read the implementation to learn — a file, a value, a count, a verified absence. That isnotes. Keep the two disjoint and neither has to hedge.
A requirement's name is the segment of its dotted path — Ordering.Placement.Recorded,
the same addressing every other node uses — and that path is also the filename of its
generated test stub (requirements/<path>.test.ts). So a name is an identifier, and two
habits break it:
- A
.in the name makes it indistinguishable from nesting. A single node namedOrders.Recordedand a nodeOrderscontaining a nodeRecordedproduce the identical path, so the address stops identifying one node and both derive the same stub file./and\redirect the stub into a directory nobody declared — a..segment walks it out of the output tree entirely — and the characters illegal in a Windows filename mean the stub cannot be written there at all. - A sentence for a name puts the claim in the address instead of in
@statement, where every surface reads it.
The loader constrains a requirement's name no more than any other node's, so both load
cleanly. verify warns about them.
| check | fails when | |
|---|---|---|
requirement.functional (levelled) |
existence | nothing implements it |
requirement.architectural (flat by default) |
universality | something violates it |
Architectural requirements are how plumbing stays out of the ledger: one uuid-primary-key rule claimed by every entity, rather than thousands of per-field entries.
By default an architectural requirement is flat — object-independent, no level, and free to name the model directly. That is the original form and still the right one for a single platform-wide policy.
Add a @level and the node opts into a tree, which is what you want when organising
non-functional requirements under a quality taxonomy. From that point it behaves exactly like
a functional node: nesting must agree with the level, and only L4/L5 may carry
@implementedBy, so a grouping tier cannot quietly start naming entities.
A workable shape, using an established taxonomy as the fixed upper structure so it is inherited rather than re-invented per project:
L1 Security (an ISO/IEC 25010 characteristic)
└ L2 Confidentiality (its sub-characteristic — or a control
│ catalogue's own category, e.g. a HIPAA
│ safeguard class, when one applies)
└ L4 invoiceTotalsAreEncryptedAtRest (the claim, bound to the model)
Levels may be skipped, so L1 → L2 → L4 is legal. Keep the upper tiers inherited and project-invariant; a project fills in the bottom.
Two things worth knowing before you adopt a taxonomy wholesale: in ISO/IEC 25010, availability sits under Reliability rather than Security, which surprises anyone trained on the CIA triad; and cost has no home in any ISO quality model, so constraints of that kind need a branch of their own.
Requirements are metadata, so they are checked on every meta verify — no subverb.
The rule worth knowing before you read a failure: a dangling @implementedBy is an error
on live/partial and allowed on planned. On planned the nodes do not exist yet —
that is the entry doing its job. Anywhere else it means the model moved and the claim went
stale, so repoint it or delete the entry.
@status is a closed enum (planned | live | partial | retired) enforced by the loader,
so a typo fails the load in every language rather than silently disabling the entry.
@trackedBy names issues or tickets and is not resolved — verify has no network.
A capability that was built and then deliberately removed is @status: retired. It is
the most load-bearing member of the enum, because it is the only one with controlled evidence
behind it: given a feature brief for a capability that had been retired, agents working from
the model alone proposed rebuilding it 24 times out of 24, each believing they were
reusing rather than reviving. One run called it "a near-exact decoy" — a retired feature is
more attractive to a retrieval-driven agent than a live one, because it was purpose-built
for exactly the request and never got complicated by contact with production. Arms carrying a
ledger caught it 19 times out of 40.
Write the statement as a prohibition. A requirement states what should be true and never
narrates what happened, and retired satisfies that rule by stating a rule in force:
{ "requirement.functional": {
"name": "orderExpiry", "@level": 4, "@status": "retired",
"@statement": "An unpaid order is never expired by a wall-clock timer",
"@counterexample": "An order cancelled by elapsed time rather than by the customer",
"@supersededBy": "acme::caps::orderHold",
"@notes": "Shipped 2026-03, removed 2026-06: a fixed window cancelled orders mid-checkout whenever a payment provider was slow."
}}@statement: "We used to expire orders on a timer" is a diary entry, and no gate will catch
it. @counterexample is where the entry earns its keep — on a retired requirement it
describes the revival, which is the thing a future reader is about to propose.
Three rules the loader enforces:
@implementedByis refused (ERR_REQUIREMENT_RETIRED_HAS_IMPLEMENTORS). A retired capability has no implementation by definition, so the references cannot dangle because they cannot exist. If deleting them feels wrong, that usually means the capability is not actually retired — an entry whose nodes are still there isliveorpartial. What used to implement it goes innotes.@supersededBynames the requirement that replaced it, and is legal here only. It is RESOLVED, so a dangling one fails the build — which is what keeps a chain walkable when the replacement is itself retired later. A prose note points one hop and rots.- It never counts toward object coverage, and is exempt from architectural universality. Retiring a capability must not silence "nothing claims this entity", and a withdrawn policy governs nothing.
One honest limit. 19 of 40 is under half, and the guardrail only fires if something routes
an agent to the ledger. meta docs emits retired entries into the requirements surface; if
your agents never read it, restoring the entry buys you a coin flip. Point at it.
@verifiedBywas retired in0.24.0, andverifyno longer looks at your tests. It asked you to name a test and then checked only that the name occurred somewhere in the test corpus — as a whole word, in any language. That generosity was deliberate (a "missing" verdict then meant the name appeared in no test file at all, which is broken in any ecosystem) but it meant the check could not tell whether the named test verified the claim. Auditing a real 19-name ledger found four that did not: one matched a comment, one a dependency-injection key in test setup, one a real test of a different claim, and one a test of the entry's output where the claim was about its source text.verifyreported clean throughout. The author picks the string, so the cheapest way to satisfy the check was always to find a name that already existed. Tying a requirement to a test is instead the job of a generator that emits the test from the requirement, making the link structural rather than chosen. Migration:docs/features/migrations/verified-by-retirement.md.
Every run prints a summary, clean or not:
meta verify — requirements: 235 entries (226 functional, 9 architectural) —
173 live, 62 partial; 55/55 entities claimed.
meta verify — requirements: 62 recorded gap(s) with no @disposition.
A gate that says nothing when it passes cannot be told apart from a gate that checked nothing — and a ledger that skipped an entire grain reads exactly like a complete one.
Alongside the gate, verify runs an authoring lint and prints it under its own heading:
meta verify — requirements: 6 authoring warning(s) (advisory — does not fail the build):
WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE [Ordering.Orders.Recorded]: name "Orders.Recorded" …
WARN_REQUIREMENT_PROSE_DUPLICATED [Ordering.Orders.Recorded]: description opens by …
Every diagnostic is addressed by the requirement's dotted path, not its bare name — two branches of a ledger may reuse a name, and a finding you cannot locate is a finding you cannot act on. The gate above prints the same way.
The two make different claims, which is why they are separate sections with separate caps. The gate says the ledger disagrees with the model — a link above the floor, nesting that contradicts a level, a reference that no longer resolves. The lint says the ledger agrees with the model but records less than its author thinks.
| Code | Fires when |
|---|---|
WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE |
The name holds a character that breaks the dotted path or the generated stub filename — ., /, \, :, *, ?, ", <, >, |, a control character, or stray surrounding whitespace. |
WARN_REQUIREMENT_NAME_READS_AS_PROSE |
The name is a sentence rather than an identifier. |
WARN_REQUIREMENT_NAME_RESTATES_STATEMENT |
The name and @statement say the same thing, so the claim is written twice and the address is one of the copies. |
WARN_REQUIREMENT_PROSE_EMPTY |
@statement or @counterexample is declared but blank. The loader requires the attribute to be present, never to say anything. |
WARN_REQUIREMENT_PROSE_DUPLICATED |
description repeats @statement — whole, or as its opening sentence — or @counterexample does. |
WARN_REQUIREMENT_INERT_DOC_SLOT |
summary is set on a requirement, where @statement already holds the one-line sentence and nothing reads it. title is NOT flagged — it is chartered as the entry's label and the generated page renders it. |
WARN_REQUIREMENT_TITLE_IS_AN_ID |
title opens with a catalogue or ticket id. Split it: the id to @trackedBy, the noun phrase stays the title. |
Every lint finding is a warning and none of them can fail your build. That is deliberate
rather than cautious: a prose check that turns verify red on upgrade teaches people to
switch the gate off, which costs more than the padding it caught. It is the same call as
object coverage, which stayed a warning because on one real estate it reported every entity
in the repository.
Mute it with --no-requirement-lint (or META_NO_REQUIREMENT_LINT=1) — the same pair
the anti-pattern advisory offers. It silences the advisory half only: the gate above still
runs and can still fail the build, which is the point of printing them as two sections.
Two things the lint deliberately will not do. It never reports a paraphrase — only an exact repeat — because a similarity threshold on prose produces findings an author can argue with, and a gate people argue with is a gate people mute. And it never asks whether a statement is true, a description useful, or a counterexample sufficient; those are the judgements the ledger exists to record, and no check reaches them.
partial is the most valuable status in the enum, because it is the only one that says
"this works, and here is what is wrong with it." A ledger with no partial entries is
usually a ledger nobody has read carefully.
But partial alone answers only half the question. It says there is a gap; it does not
say what we decided about it. That second answer is @disposition:
@disposition |
means |
|---|---|
| (absent) | undecided — nobody has ruled on this gap yet |
accepted |
the gap is understood and deliberately not being closed |
deferred |
it will be closed, but not now |
These are deliberately kept apart from @status. Collapsing them would make "there is a gap"
and "we chose to live with it" the same fact, and you would lose the ability to ask the most
useful question a review can ask: which gaps has nobody ruled on? That is what the summary
line counts.
@disposition is meaningful on planned and partial only. On any other status the
decision is the status, and a second one could only agree or contradict — so verify warns.
Deferring without a ticket is how a known problem becomes an unknown one. verify warns
on deferred with no @trackedBy; accepted needs no ticket, because the decision is that
there will be no work.
{ "requirement.architectural": {
"name": "monetaryFieldsDeclareTheirCurrency",
"@status": "partial",
"@disposition": "deferred",
"@trackedBy": ["acme/platform#412", "PLAT-77"],
"@statement": "A field holding money declares that it holds money, and in which currency.",
"@counterexample": "A long summed with another long of a different currency, and nobody notices.",
"@implementedBy": ["acme::billing::Invoice"]
}}What to do with a partial nobody intends to finish: say so, with
@disposition: accepted — the gap is understood and deliberately not being closed. That is
a more honest record than a gap perpetually about to close. If the capability itself is gone,
delete the requirement; there is no status meaning "we used to do this".
planned records an intention: a roadmap item, or a placeholder you want fixed in the model
before anyone builds it. Two rules make it safe.
Its references may dangle. You can name nodes that do not exist yet, which is the point — you can write the requirement before the entity.
It never counts toward object coverage. If planning silenced the unclaimed-entity warning, the cheapest way to clear coverage would be to declare an intention, and the gate would be measuring ambition rather than work. A planned architectural requirement is likewise exempt from the universality check — a policy that is not built yet is supposed to apply to nothing.
Pair it with @trackedBy to link the ticket it will be built under.
It proves referential integrity. It cannot prove a status is true, or that a node genuinely implements the requirement claiming it — no test can.
Coverage is also narrower than it sounds: entity grain only. object.value and
object.projection are exempt, and fields, views, validators and identities are never
required to be claimed. Green means "every entity is claimed by something", not "every node
is described". Unclaimed entities produce a warning, never a failure.
spec/capability-ledger.md— the full reference: schema, levels, the loader/verify split, and the reasoning behind each ruleextending-with-providers.md— adding your own vocabulary, and what modularity does and does not mean
{ "metadata.root": { "package": "acme::shop", "children": [ { "requirement.functional": { "name": "ordering", "@level": 3, "@status": "live", "@statement": "Every placed order is recorded before payment is attempted.", "@counterexample": "A payment attempted against an order that was never stored.", "children": [ { "requirement.functional": { "name": "orderRecord", "@level": 4, "@status": "live", "@statement": "An order records what was bought, by whom, and when.", "@counterexample": "An order row that cannot say who placed it.", "@implementedBy": ["acme::shop::Order"] }} ] }} ] }}