All documents

Resources

Requirements — How I Write a Spec

9 min readAuthor: Tim Georg — © 2026Licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0).See https://creativecommons.org/licenses/by/4.0/legalcode.
DownloadMarkdown (.md) · 11 KB

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:

  1. 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.
  2. 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.
  3. 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.

RangeHoldsExamples
00The index. Navigation only, no content of its own00_INDEX.md
01–02Core: vision and principles, system context01_VISION_AND_PRINCIPLES.md, 02_SYSTEM_CONTEXT.md
10–19Feature domains, one per vertical10_COLLECTIONS.md, 13_LIBRARY_VIEW.md
20–29Data contracts: the shapes on disk and over the wire21_DATA_MODEL_COLLECTION_JSON.md
30–39Architecture and planning extensions, when a repo needs them30_REKORDBOX_INTEGRATION.md, 34_MCP_SERVER.md
90–93Global files that cut across every domain90_NFRS.md, 91_INVARIANTS.md, 92_PLANNED_FEATURES.md, 93_OUT_OF_SCOPE.md
99The changelog99_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:

ValueMeaning
ActiveSpecified, not built
PartialSome acceptance criteria hold, the rest do not
DoneEvery acceptance criterion holds
ProposedWritten ahead of a decision; may never be built
DeferredDeliberately postponed, with a named version or trigger
SupersededReplaced by another requirement — name the replacement
ObsoleteNo 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.

IDCategoryRequirement
NFR-1PerformanceCollection create (chunking + indexing) for a 100-page PDF completes in under 60 s on a mid-range CPU
NFR-3SecurityNo 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.

IDConstraintEnforcement Points
INV-1Embedding model immutable per collection after first chunk indexingUI disable, hook validation, backend rejection
INV-4Collection IDs are UUIDs and validated for all file operationsPath-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.

IDFeatureNotes
PF-1Visual Fabric.js canvas editor for SetsDrag 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.

TopicRationale
Cloud or remote backendConflicts with local-first principle
Multi-user sharingProduct 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 the folder-index skill 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

  1. Open the domain file, not the index.
  2. Change the acceptance criterion, or add the next free ID.
  3. If the change is a decision, record it in 99_CHANGELOG.md in the same commit.
  4. If a file was added, renamed or retired, fix 00_INDEX.md.
  5. 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 Done status 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:

RepoRequirementsNotes
SmartimizeSmartimize-App/docs/requirements/The reference long form: tabular requirements with ACn lists, IDs COL, CHAT, KG, MM, LIC
Klangkuratordj-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.