spec-contract
The content contract for a design doc — what must be IN it, as against make-spec which owns how it LOOKS. Synthesised from five primary sources read verbatim (Lynch/Refactoring English + his worked ex
Files6
View on GitHubSKILL.mdskills/governance/spec-contract/SKILL.md
GitHubspec-contract — what must be in a design doc
make-spec owns the shell: the theme, the tag vocabulary, the filename
convention, <title>/<h1> parity. Its self-test checks the CSS.
This skill owns the contents. They compose; neither replaces the other.
The rule everything else derives from
What's the penalty for being wrong?
A design doc admits a decision by the cost of reversing it after implementation — not by importance, not by effort, not by how much the author thought about it. Choosing C++ over Rails is unrecoverable at 200k lines and belongs in the doc. A "Load more" button is fixable in an afternoon and arguing about it in review is pure waste.
Every check below is downstream of that rule. Provenance for each, with verbatim
citations: references/sources.md.
Layer 1 — deterministic (scripts/spec_check.py)
# one doc, profile inferred from the path
python3 scripts/spec_check.py docs/specs/2026-09-16-thing.html
# explicit profile, JSON for a gate
python3 scripts/spec_check.py docs/adrs/*.md --profile adr --json
# reversal cost becomes blocking rather than advisory
python3 scripts/spec_check.py docs/specs/thing.md --strict
# resolve every external URL (network; off by default so CI stays hermetic)
python3 scripts/spec_check.py docs/specs/thing.md --check-links
Exit 0 clean · 1 a required check failed · 2 bad invocation.
| Check | Fails when | Source |
|---|---|---|
C1-missing-section |
a required class for the profile has no heading | LYNCH |
C1-missing-recommended |
(warn) a recommended class is absent | LYNCH |
C2-no-status |
no Status: field — a doc with no state can never be superseded |
NYGARD, OXIDE |
C2-dangling-supersede |
status is superseded/deprecated and names no successor |
NYGARD |
C2-bad-status |
(warn) status outside the known state set | OXIDE |
C3-no-reversal-cost |
(warn; --strict → fail) no one-way/two-way-door declaration |
LYNCH |
C3-vague-reversal-cost |
(warn) reversal cost named but not graded | LYNCH |
C4-negated-goal |
(warn) a non-goal reads as a negated requirement | |
C5-thin-alternatives |
fewer than two alternatives named | RUST, GOOGLE |
C5-no-rejection-reason |
no option states why it was not chosen | RUST |
C6-drawbacks-without-cost |
a drawbacks section that states no cost — blocking where the class is required, advisory where it is only recommended | NYGARD, RUST |
C7-open-question-without-next-step |
(warn) an open question with no resolution path | LYNCH |
C8-unmeasurable-acceptance |
acceptance with no number, unit or runnable command — blocking on plan, advisory elsewhere |
LYNCH |
C9-implementation-manual |
no trade-off language anywhere in the document | |
C10-oversized |
(warn) beyond ~20 pages — GOOGLE's signal to split the problem | |
C10-stub |
(warn) under 250 words | NYGARD |
C11-dead-link |
(opt-in --check-links) a cited URL does not resolve |
— |
C11-unsafe-link |
(opt-in --check-links) a cited URL resolves to a private, loopback or link-local address |
— |
Profiles
A profile is a claim about which reversal-cost axes a document type exists to
pin down. Inferred from the path only (docs/adrs/ → adr, docs/plans/ →
plan, /rfd|/rfcs/ → rfd, else spec); override with --profile.
note is never inferred. An earlier cut downgraded any document under 700 words
to it, and cross-review found the consequence: a thirteen-word implementation
manual committing a one-way door exited 0, and the cheapest way to pass was to
delete words. note is right for a short design-bearing doc — as a choice
someone makes and a reviewer can see, not a silent property of length.
| Profile | Required | Recommended |
|---|---|---|
adr |
context · design · alternatives · drawbacks | objective · acceptance |
spec |
objective · design · non-goals · alternatives | context · goals · drawbacks · acceptance · open questions |
plan |
objective · goals · acceptance | non-goals · design · open questions |
rfd |
objective · design · alternatives · drawbacks | context · open questions · acceptance |
note |
design | objective · alternatives |
alternatives is required on adr, spec and rfd because all five sources
say so in the same words. It is not required on plan — a rollout plan
sequences work whose design decision was already taken elsewhere — nor on
note. acceptance is required on plan alone: a plan with no definition of
done is a wish list. test_r1_nb1_skill_md_alternatives_claim_matches_the_table
asserts this paragraph against PROFILES, because the first version of it
claimed "every profile but note" and the table disagreed.
Sections resolve by class, not by name. "Missing features", "Out of scope"
and "What this is not" are all non_goals. Widen a class by adding a synonym to
SECTION_CLASSES — the single producer — never by renaming a heading to satisfy
the checker. Renaming to pass a gate is gaming it.
Layer 2 — judgment (references/rubric.md)
A doc can pass all 18 findings and be worthless: two strawman alternatives, a non-goal nobody would have assumed, an SLO chosen because it was easy to measure. That is not a gap in the script — it is the half that is irreducibly a judgment.
Five axes, 0-3, pass ≥11/15 with no axis at 0:
| Axis | Question |
|---|---|
| R1 reversal-cost fit | are the documented decisions the expensive ones — and is any expensive one missing? |
| R2 alternative realism | would a competent engineer actually have chosen one of these? |
| R3 non-goal load-bearing-ness | would a reader have assumed these were in scope? |
| R4 trade-off substance | delete the alternatives section — does the doc's meaning change? |
| R5 legibility | is the first screen intelligible to the widest expected reader? |
Grade with a different model than the one that wrote the doc (P20: the writer
cannot be the final judge). R1 = 0 — a silent one-way door — stops the doc
outright; R2/R4 at 0-1 route to full cross-review, because the design is
unargued and rewriting prose will not fix it.
When a design doc is not warranted
The honest answer is often none. Both Lynch and Google give a test, and they agree: write one when the solution is ambiguous and the decisions are expensive. Google's disqualifier is the sharper one —
"If a doc basically says 'This is how we are going to implement it' without going into trade-offs, alternatives, and explaining decision making … then it would probably have been a better idea to write the actual program right away."
That is what C9 detects mechanically. If the only honest answer to "what were
the alternatives?" is "there weren't any", the correct output is code, not a doc.
Composition
| With | How |
|---|---|
make-spec |
This skill decides the contents; make-spec renders them. Run spec_check.py on the output before the doc goes out. |
cross-review (P20) |
The judgment layer is a P20 stratum applied to a design rather than a diff. An R2/R4 failure escalates to the full gate. |
decision-log |
Already asks "one-way door or two-way door?" — that is C3's field. A logged decision that reaches doc scale becomes an adr-profile doc. |
bookkeeping (P6) |
A doc that passes both layers is citable provenance for an entity page; one that fails C9 is an implementation manual and cites nothing. |
checkit |
Supplies R2's input — you cannot judge whether an alternative is real without having read what else exists. |
Review ordering
Grading a draft and reviewing it are different disciplines. The ordering is
counterintuitive enough to state, and it is in references/rubric.md: one
preliminary reviewer on comprehension only (ten at once triggers the bystander
effect), then widen on substance, and the meeting is the last step with an
agenda naming only the open issues. Every answer goes back into the doc — a
reviewer's confusion resolved in a thread fixes nothing for the next reader.
Anti-rationalization
| Excuse | Reality |
|---|---|
| "The sections are all there, so the doc is fine." | Layer 1 checks shape. Shape is necessary and nowhere near sufficient — run the rubric. |
| "I'll add non-goals if a reviewer asks." | The scope boundary is what review relitigates. Undefended scope is the cost you pay later, in the meeting. |
| "There weren't really any alternatives." | Then there was no decision, and C9 is right: write the code. If there was a decision, the alternatives exist and you skipped them. |
| "I'll rename the heading so the checker passes." | Classes are synonym-matched precisely so you don't have to. Renaming to pass is gaming the gate; add a synonym, or write the section. |
| "This is just a small change." | Then use the note profile, which requires one section. Small is a size, not an exemption from stating what you gave up. |
| "The doc is accurate — I wrote it last month." | Staleness has no failure signal. C2 exists so a doc can be superseded; a doc with no status silently becomes a false description of what shipped. |
| "I reviewed it myself and it reads well." | Reading well is R5, the cheapest axis. The writer cannot grade R1, R2 or R4 on their own work. |
Calibration
Measured 2026-09-16.
The corpus, defined once. find docs/specs docs/plans docs/adrs -maxdepth 1 -type f \( -name '*.md' -o -name '*.html' \) → 105 documents. Recursive
would give 110; the 5 extra live in kinetic-orb-cinema/, kinetic-workspace/
and motor-ontologia/, which are project subdirectories rather than design
docs. Every percentage below has 105 as its denominator. The supersession
measurement further down uses 98 — the HTML subset — and says so, because
its subject is an HTML front-matter convention.
| Corpus | Result |
|---|---|
Lynch's own worked example (little-moments-design-doc, 2,667 lines), --profile spec |
zero failures |
This workspace's 105 docs/specs|plans|adrs documents |
1 passes |
Section coverage across those 105, by class: design 52.4% · open questions 25.7% · drawbacks 18.1% · objective 12.4% · context 10.5% · non-goals 7.6% · alternatives 5.7% · acceptance 5.7% · goals 1.9%. The corpus documents what was chosen and not what was ruled out — the inverse of reversal cost, and the reason this skill exists.
Those figures moved twice under review and the movements are the point. An
earlier version of this paragraph published design 55.2%; substring matching
without word boundaries was counting "Conflict resolution" as a design section,
and the corrected figure is 52.4%. alternatives fell 7.6% → 5.7% when bare
"rejected" stopped being a section synonym. non-goals has not moved through
any parser change — it is the one number no repair has touched.
Supersession runs the wrong way, measured. Dogfooding the checker against
the workspace's own HTML docs turned up the sharpest number in this file. Of the
98 HTML documents in docs/specs|plans|adrs (the subset, not the 105 above —
the convention under test is an HTML front-matter one), 38 mention superseding
or replacing another document, and 2 carry a status marking themselves as
superseded.
The relation is recorded in the forward direction, in the new doc, where it
helps nobody: a reader who lands on the old document gets no signal that it
has been replaced. NYGARD's rule is the other direction — "we will keep the old
one around, but mark it as superseded" — and it is the only mechanism in any of
the five sources by which a design doc can stop being a false description of
what shipped. C2 exists because of that asymmetry.
A caveat on the arithmetic: 38 − 2 = 36 is a subtraction, not a count of
documents needing a marker. The 38 forward-mentions live in the new documents;
the ones that need a superseded status are the old ones they point at, and
this measurement never resolves those references. The asymmetry is the finding;
36 is not a population.
What the corpus number is not evidence for. An earlier draft of this section
reported 1/105 and attributed the improvement to the required/recommended split.
Cross-model review challenged that attribution and re-measurement refuted it:
both the all-nine-required arm and the shipped split pass 0/105. The split
buys zero existing documents. It is derived from the five sources — required
is the intersection of what all five independently call load-bearing — and the
evidence that it is not merely strict runs the other way: a design doc written
to published best practice clears it. A corpus pass rate says what the corpus
is like, not where the bar belongs, and reporting it as if it justified the bar
was the error.
Re-measuring
The corpus figures live in another repository, so no test in this one can hold them honest — and they drifted twice under review before anyone noticed. Re-run them with:
python3 - <<'PY'
import sys, pathlib, collections
sys.path.insert(0, "scripts"); import spec_check as sc
import os
root = pathlib.Path(os.environ.get("BROOMVA_ROOT",
pathlib.Path.home() / "broomva")) # override for another checkout
docs = [p for d in ("specs", "plans", "adrs")
for p in (root / "docs" / d).glob("*.*") if p.suffix in (".md", ".html")]
have, fire, npass = collections.Counter(), collections.Counter(), 0
for d in docs:
r = sc.check(d, None, False, False)
npass += not r.failed
for c in r.sections: have[c] += 1
for f in r.findings:
if f.severity == "fail": fire[f.check] += 1
print(f"{npass}/{len(docs)} pass")
print({k: f"{100*v/len(docs):.1f}%" for k, v in have.most_common()})
print(dict(fire.most_common()))
PY
The self-contained numbers — test count, mutant count, finding-id count — ARE
enforced: tests/mutation.sh reads its own baseline, and
test_r1_b8_documented_check_ids_match_the_code asserts the id set and the
prose count against the source. Those three cannot drift silently. The corpus
percentages can, and this is the command that catches it.
Precision, per check, on live firings
A check is only worth what it is right about. Measured across the 105-document corpus:
| Check | Firings | Precision |
|---|---|---|
C1-missing-section |
331 | structural — a heading class is present or it is not |
C2-no-status |
23 | 22/23, every firing inspected |
C9-implementation-manual |
13 | all 13 have zero trade-off expressions in prose |
C2-dangling-supersede |
2 | both inspected; both name no successor |
C5-thin-alternatives · C6 |
0 · 0 blocking | C6 demoted to advisory, below |
Sample size is not a precision measurement. An earlier version of this table
claimed C2-no-status at "10/10 on a random sample" of 36 firings. Reviewing all
36 found 14 false positives the sample had missed — five distinct mechanisms,
four of them introduced by the fixes of the two preceding rounds. The numbers
above come from inspecting every firing, and the four that a crude grep
flagged as suspect turned out on reading to be true positives: status: FlagStatus; is TypeScript in a code block, two more are per-item statuses in
body prose. Auditing with a proxy for the thing is how the first number was
wrong; the second was produced by looking.
A check was deleted on this evidence. C5-unjustified-alternative asked
whether each option carried prose beyond its own name. Its precision on live
firings was 0 of 2 — both were the header row of a comparison table, which
is the commonest real idiom for the section. A rule wrong on every firing it
produces is not a rule with a bug, and tuning it further would have meant
editing the corpus to fit the rule. Whether an option is argued well is rubric
R2, which is where that judgement belongs.
Two checks are advisory because their precision could not be demonstrated.
C6-drawbacks-without-costfired 4 times; at best 2 were true. One fires on a section headed "Two structural prohibitions, not compliance costs" — pulled into the class by the word "costs" in a heading that says it is not a costs section — and one on "Negative / accepted trade-offs" whose real costs ("far less to build than OpenRaft", "no consensus machinery to operate") are not inCOST_LANGUAGE. Enumerating cost vocabulary is the same losing game as enumerating status vocabulary. Whether stated consequences are honest is R4.C9's floor was 2 expressions and is now 1. The floor of 2 was a second defence against a hazardbody_prosealready closed, and it cost 15 false failures (28 → 13). All 13 remaining have literally zero trade-off vocabulary.
Known limitation: C9 reads English. Two of its firings are Spanish-language
documents where it cannot see an argument that may be there. Treat a C9 failure
on a non-English doc as unmeasured.
Tests
python3 -m pytest tests/test_spec_check.py -q # 162 tests
bash tests/mutation.sh # incl. a NULL control that must SURVIVE
Every check carries a positive and a negative control. C4 found nothing
across all 105 real documents; that is a measured zero with a known cause — only
8 of them have a non-goals section for it to read — not an unfalsified silence,
and the four positive controls prove the rule fires.
The mutation sweep reports killed 47/47, and three properties of it are load-bearing because the first version had none of them and still printed a clean score:
- A NULL mutant must SURVIVE. A no-op edit that fails the suite means the harness is broken and every other verdict it printed is void.
- A nonzero exit is not a kill.
PYTEST_ADDOPTS=--bogusmade the first version report "killed 14/14" while pytest collected nothing. A kill now requires the same test count as the clean baseline plus aFAILEDline. - A crash is not a kill. A mutant that dereferences
Noneproves the code path runs, not that any assertion is sensitive to it. Those are reportedCRASHand fail the run.