This file applies to structure/ and inherits the repository-wide rules in AGENTS.md.
INDEX.md is the reading order and the source-to-doc map; it is generated from
manifest.json, so it is never the file you edit to record something.
A doc in this folder states the contract that holds right now, in the present tense, for one subsystem. That is the whole job.
- Public user workflows belong in
docs-site/. - Open work, triage, and investigation belong in
devlog/. - Superseded or alternative reasoning belongs in
decisions/, not in the doc body. - Unreleased security findings belong in scratch space and nowhere in this repository. The rule in
the root
AGENTS.mdbinds this folder without exception.
If you cannot write a sentence in the present tense about how the system behaves today, it is not a structure doc.
- File names are kebab-case, start with a letter, and sit at most one directory deep:
providers/google.md, notproviders/google/wire.md, and not04_transports.mdor04-transports.md. - Ordering lives in
manifest.json, never in a filename. Leading digits are rejected outright. The oldNN_topic.mdscheme produced two09_files and made splitting a doc cost a renumber, which is how04_transports-and-sidecars.mdreached 1,860 lines before this folder was reorganised. - A doc stays under the line budget in
manifest.json. Over budget, split it along a topic boundary and give each half its own manifest entry. Agrace.oversizeDocsentry is for a split already planned; the gate drops it again once the doc is back under budget. - Stage a new file before running the gate. Repository paths are resolved through the git index,
so a file you have written but not
git added does not exist as far as the check is concerned. That is deliberate: CI runs on a clean checkout, and a gate that passed on untracked local files would disagree with it. - Know what the budget does and does not do: it is a line count, so a doc written as a wide table can carry far more prose per line than one written as paragraphs. It bounds the runaway-file failure, not density.
Each doc lists the source areas it describes, in its documents array. INDEX.md publishes
the inverse.
- An area can be described by more than one doc, and usually is. These docs are organised by
topic;
src/is organised by module.src/server/is genuinely described by the management-API doc, the Responses transport doc and the Images doc. An earlier revision of this folder demanded exactly one owner per area, and that rule was simply false here — a false rule is worse than none, because the gate reports green while the map sends a maintainer to the wrong doc. - Changing an area obliges the same change to review every doc listed for it. Review fan-out is unchanged by contract authority: update the authority when the shared contract changes, and update a dependent only when its local explanation or consequence changes. A reviewed document whose content remains accurate does not need copied unchanged prose.
- Describing an area means naming a path inside it. If a doc explains a subsystem without ever citing
a path, the map cannot see it, and the area lands in
grace.undocumentedSourceAreasinstead — which is a signal to add the path reference, not a place to park work. The gate checks the weak form of this: adocumentsentry is rejected when the doc never names the area or any path under it. Naming the directory itself passes, which a table of directory names does, so the check catches an invented claim but does not prove the doc says anything useful about the area. That part is review. - A new
src/<area>/or top-levelsrc/*.tseither joins a doc'sdocumentslist or is recorded ingrace.undocumentedSourceAreaswith a reason. The gate rejects one that is neither.
What the map still does not do: it cannot tell you that two docs describe the same behavior in contradictory words. Avoiding that is a review judgement. Prefer one statement and a link over two statements that will drift apart.
Cross-cutting authority is declared by the optional versioned contracts object in
manifest.json. Each entry has a stable kebab-case id, one owner with a
manifest-declared document and heading anchor, and a dependents array of manifest-declared
documents. Every dependent links the exact owner anchor. The registry supplements the source map;
it never narrows which documents a source change requires review of.
The gate proves declared topology: identifiers are unique, files and anchors exist, and each dependent carries the declared link. It does not compare prose or prove that the owner statement is behaviorally correct. Those remain review judgements.
decisions/ADR-NNNN-<slug>.md holds the reasoning: intent, prior constraints, alternatives, the
choice, why, and consequences.
- One record has exactly one owning doc, which links it with a
> Decision record:line. - Numbers are permanent and unique. They are deliberately not required to be contiguous: two branches that each add a record would otherwise both take the next number and collide on merge.
- Records are historical. When the contract changes, edit the doc body and add a new record; do not rewrite an old one to match. For the same reason the gate does not validate the repository paths a record names — a record describes a past tree, and holding it against the present one would force you to falsify it.
- A record's title names the doc section it was recorded under, not the decision it contains. That is
why every title reads
decision recorded under "<section>": the records extracted during the 2026-09-11 reorganisation took their heading from the section they sat in, and a title that looked like a decision name but was not would send a maintainer to the wrong record. Read the body. - Ownership is the
> Decision record:link, and nothing else. A record path mentioned in prose or shown inside a fenced example is not a claim, so an illustration cannot make a doc a second owner. The link has to land insidedecisions/; pointing it elsewhere is rejected rather than matched on the filename.
overview.md is the invariant index. Each entry needs a stable INV-<AREA>-NN id, and a
bound entry adds an Enforced by line naming exactly one tests/**.test.ts path, with that id repeated
in a comment inside the test.
Be precise about the strength of that binding, because it is easy to overstate:
- It proves the test file exists and claims the id. Deleting or renaming the file fails the gate.
- It does not prove the assertions inside still cover the rule. Moving the assertions to another file while leaving the comment behind passes. Only review catches that.
An invariant with no honest test goes in the index without an Enforced by line and with an entry
in grace.unboundInvariants explaining why. Naming a test that would pass while the rule was
violated is worse than admitting the gap: it converts an open question into false assurance.
- Write or move the file.
- Add or update its
manifest.jsonentry:path,tier,title,scope,documents. - Add or update any affected contract authority and dependent links in
manifest.json; review every document mapped to the changed source area even when its text remains accurate. bun run structure:indexto regenerateINDEX.md.bun run structure:checkuntil it is green.
bun run structure:check — also run by tests/ci-workflows/structure-ssot.test.ts, so it blocks CI —
verifies that:
- every doc on disk is in the manifest and every manifest doc exists, exactly once;
- file names are kebab-case, letter-initial, and at most one directory deep;
- no doc exceeds the line budget, and no grace entry outlives the split it promised;
- every relative link resolves, including its
#anchor; - a fragment-only link resolves against its own document, which is where one broken anchor was hiding;
- every backticked repository path a doc names is real, checked against the git index rather than
the filesystem —
existsSynccannot tell a tracked file from untracked local leftovers, and it is case-insensitive on Windows and case-sensitive on Linux CI, which would make the gate mean something different on each machine; - no doc body carries either inline decision-log marker: the bracketed Decision-Log heading that the old layout used, or the Korean bullet template that followed it. Reasoning written as ordinary prose is not detectable and stays a review judgement. The check is a literal match, which is why this line describes the marker instead of quoting it;
- every decision record is linked from exactly one doc, and no number is reused;
- every bound invariant names an existing test that names the id back, and every unbound one is recorded with a reason;
- every
src/directory and top-level module is described by a doc or recorded as undescribed; - contract ids are unique, owner and dependent documents exist in the manifest, owner anchors exist, and every dependent links its declared authority without listing the owner as a dependent;
- the manifest itself parses and has the shape the gate expects, reported as a failure rather than a stack trace;
overview.mdexists, because its absence would otherwise silence every invariant check at once;INDEX.mdmatches what the manifest generates, compared after newline normalisation so a CRLF checkout is not a failure.
Contract checks establish declared authority and links only. They make no automatic claim that an owner's prose is semantically complete or that a dependent's explanation is behaviorally correct.
Checks are scanned with fenced code blocks removed, so an example inside a fence does not trip a rule it is only illustrating.
One boundary worth stating, because it looks like a gap and is a deliberate one: a backticked token is
treated as a repository path only when its first segment is a top-level entry this repository has or
used to have. That covers root files too, so package.json and MAINTAINERS.md are checked directly,
not only through the links that point at them. What is NOT checked is a bare filename that was never
a top-level entry, because these docs name runtime files that live in a user's home rather than in
the repository — config.toml, models_cache.json, ocx.pid — and validating every filename-shaped
token would reject them.
The top-level set deliberately includes roots that no longer exist, such as go/. Deriving it from
the current tree alone would make every reference to a deleted directory invisible at exactly the
moment those references go stale.