Ressourcen
Requirements — How I Write a Spec
This is how I write a requirements document: how the files are laid out in a repo, how a requirement is identified and worded, and the rules that keep a spec from quietly describing something that is no longer true.
What a requirements document is
A requirements document answers one question: what must be true when this is done? Not how it is built (that is the plan), not what was built (that is the changelog).
Three properties matter, in this order:
- Addressable. Every requirement has an ID, so a plan, a PR, a test and a postmortem can all point at the same thing without quoting prose that may have moved.
- Testable. Every requirement states how you would know it is satisfied. "Fast" is not a requirement; "first token within 10 s on a quantized 7B model" is.
- Alive. A requirement that changes silently is worse than no requirement. Change goes through the file and the changelog, in the same commit.
A spec is not a wish list and not a design document. If a sentence would be equally at home in an ADR, it belongs in the ADR.
The folder contract
All product requirements live in docs/requirements/, one file per domain, with a
numeric prefix that says what kind of file it is. The number is the file's address; it
does not change when the content does.
| Range | Holds | Examples |
|---|---|---|
00 | The index. Navigation only, no content of its own | 00_INDEX.md |
01–02 | Core: vision and principles, system context | 01_VISION_AND_PRINCIPLES.md, 02_SYSTEM_CONTEXT.md |
10–19 | Feature domains, one per vertical | 10_COLLECTIONS.md, 13_LIBRARY_VIEW.md |
20–29 | Data contracts: the shapes on disk and over the wire | 21_DATA_MODEL_COLLECTION_JSON.md |
30–39 | Architecture and planning extensions, when a repo needs them | 30_REKORDBOX_INTEGRATION.md, 34_MCP_SERVER.md |
90–93 | Global files that cut across every domain | 90_NFRS.md, 91_INVARIANTS.md, 92_PLANNED_FEATURES.md, 93_OUT_OF_SCOPE.md |
99 | The changelog | 99_CHANGELOG.md |
One number, one file. Two files sharing a prefix (15_ACCOUNT_AND_LICENSE.md beside
15_MODEL_MANAGER.md) means the number no longer identifies anything, and every link that
says "see 15" becomes ambiguous. Take the next free number instead.
Why split at all. Most work touches one vertical. One file per vertical means
a feature PR touches one requirement file plus the changelog, and two engineers
working on different domains never conflict. The alternative — one REQUIREMENTS.md
that everything appends to — turns every change into a merge conflict and makes
"what does the spec say about X" a full-file read.
A single monolithic Requirements.md is frozen, not maintained. When a repo still
has one from before the split, it stays for historical reference and says so at the top.
Editing both is how the two copies drift apart.
The thin entrypoint. If tooling or habit expects docs/REQUIREMENTS.md, keep it as a
navigation stub: a status line and links into docs/requirements/. It holds no
requirement text. A stub that still carries full sections is a second source of truth
with a shorter deadline.
Requirement anatomy
One requirement is one headed block:
### COL-1 - Create collection from PDF files
Status: Done
Priority: P0
Acceptance criteria:
- AC1: Only .pdf files are accepted; non-PDF selection raises a descriptive error.
- AC2: Duplicate basenames in one request are rejected before any disk write.
- AC3: PDFs are copied to collection sources/ so originals can move/delete safely.The ID is a short domain prefix and a stable integer: COL-1, CHAT-3, MM-17,
LIC-4. The prefix is the domain's abbreviation, chosen once and reused in plans,
issues, commit messages and test names. The integer never gets reused after a
requirement is dropped — a dead ID is a tombstone, not a vacancy.
Status: is one of:
| Value | Meaning |
|---|---|
Active | Specified, not built |
Partial | Some acceptance criteria hold, the rest do not |
Done | Every acceptance criterion holds |
Proposed | Written ahead of a decision; may never be built |
Deferred | Deliberately postponed, with a named version or trigger |
Superseded | Replaced by another requirement — name the replacement |
Obsolete | No longer applicable — say why, and when it stopped being true |
Superseded and Obsolete are not the same thing, and neither is Done. A requirement
whose feature was removed is Obsolete, not Done; leaving it as Done tells the next
reader the behaviour still exists.
Priority: is P0 (blocks the release), P1 (needed for the release), P2
(after the release), P3 (someday). One value, no combinations.
Acceptance criteria are numbered AC1…ACn and each one is observable from outside
the code. A criterion that names an internal function call is an implementation detail
in the wrong document; a criterion that says "works well" is not a criterion.
Prose is allowed, structure is not optional. A domain file may be mostly prose when
the requirement is a contract with many clauses. What it may not do is lose the ID: if
there are no ### PREFIX-n headings, a plan cannot cite the requirement and a
postmortem cannot say which requirement was ambiguous.
The four global files
Four shapes are shared across every repo, because each one answers a question that does not belong to any single domain.
90_NFRS.md — non-functional requirements, one row per requirement.
| ID | Category | Requirement |
|---|---|---|
| NFR-1 | Performance | Collection create (chunking + indexing) for a 100-page PDF completes in under 60 s on a mid-range CPU |
| NFR-3 | Security | No document, query or user data leaves the local machine during normal operation |
Categories are a small closed set: Performance, Security, Reliability, Portability, Usability, Offline, Data, Architecture, Backup. A number that has to be measured — a threshold, a budget, a limit — belongs here with the measurement method beside it, not in a feature file where nobody will find it.
91_INVARIANTS.md — properties that must never be false, with the place that
enforces them.
| ID | Constraint | Enforcement Points |
|---|---|---|
| INV-1 | Embedding model immutable per collection after first chunk indexing | UI disable, hook validation, backend rejection |
| INV-4 | Collection IDs are UUIDs and validated for all file operations | Path-resolution guard entrypoint |
The third column is what separates an invariant from a wish. If nothing enforces it, it is either a requirement or a note, and putting it here makes the table lie.
92_PLANNED_FEATURES.md — the backlog, grouped by release, one line per item,
each pointing at the issue or plan that carries the detail.
| ID | Feature | Notes |
|---|---|---|
| PF-1 | Visual Fabric.js canvas editor for Sets | Drag blocks as nodes, draw connections as flowchart |
A planned feature is not a requirement until it has acceptance criteria. Until then it is one line and a link.
93_OUT_OF_SCOPE.md — explicit non-goals, with the reason.
| Topic | Rationale |
|---|---|
| Cloud or remote backend | Conflicts with local-first principle |
| Multi-user sharing | Product is single-user desktop |
This file exists to end the same conversation twice. A non-goal without a rationale gets re-proposed every few months; with one, the next discussion starts from the answer.
The changelog rule
99_CHANGELOG.md is append-only, newest first, one ## YYYY-MM-DD section per change,
each naming the requirement IDs it touched and why.
The rule that makes the whole system work: a behaviour change updates the matching
requirement file and 99_CHANGELOG.md in the same change. Not a follow-up commit, not
a cleanup ticket. If the spec is updated later, it is updated wrong, because the person
who knew why has moved on.
The changelog is not a git log. It records decisions about requirements — a new requirement, a changed criterion, a requirement retired as obsolete — and it survives the history being rewritten.
The index rule
00_INDEX.md lists every file in the folder with a one-sentence summary of what it
specifies, ordered by number, grouped by the ranges above. It is the only way into the
folder; a file that is not in the index does not exist as far as a reader is concerned.
Two conventions collide here and a repo has to pick one:
- The vault convention is
00_Index.md(capital I), because every Obsidian folder uses it and thefolder-indexskill generates it. - The requirements convention in the existing repos is
00_INDEX.md(all caps), because the core files are all caps.
Keeping both — one as a vault index, one as the requirements index — duplicates the navigation and guarantees they disagree. Pick per repo and delete the other.
Changing a requirement
- Open the domain file, not the index.
- Change the acceptance criterion, or add the next free ID.
- If the change is a decision, record it in
99_CHANGELOG.mdin the same commit. - If a file was added, renamed or retired, fix
00_INDEX.md. - If the change alters what must be built, the plan and the issue get the same treatment — a spec that is right while the plan is stale is only half right.
Tightening a criterion is a change like any other. Loosening one because the build missed it is how a spec becomes a description of whatever was built.
What a gate cannot check
A script can check that every file has an index entry, that IDs are unique, and that the changelog was touched. It cannot check whether a requirement is any good. These stay with review:
- Whether an acceptance criterion is observable from outside the code.
- Whether an NFR number has a measurement method, or is a number someone liked.
- Whether an invariant's enforcement column is true, or aspirational.
- Whether a
Donestatus still matches the running product.
That gap is deliberate. A requirement nobody can verify is a rule people will route around.
Where this is implemented
The scheme was extracted from two repos that arrived at it independently:
| Repo | Requirements | Notes |
|---|---|---|
| Smartimize | Smartimize-App/docs/requirements/ | The reference long form: tabular requirements with ACn lists, IDs COL, CHAT, KG, MM, LIC |
| Klangkurator | dj-crate-explorer/docs/requirements/ | Same split, prose-heavy domains, IDs written as REQ-34-PR inside headings |
Both follow the folder contract, both carry 90–93 and 99, and both let a plan cite
a requirement by ID. That is the part worth keeping; the ID formatting inside a heading
is a per-repo choice.