Collections: ADRs, Runbooks and Audit Findings
Some folders hold many documents of the same kind: a folder of architecture decision records, a folder of runbooks, the findings of a technical audit. Stele can list such a folder for you. Write each record as its own Markdown file with a few frontmatter fields, and Stele adds a table of them to the folder's landing page, keeps it up to date on every push, and links related records to each other. You never write or maintain the list.
A folder Stele lists this way is a collection, and each file in it is an item.
The short version
- Put your ADRs in
docs/adr/, your runbooks indocs/runbooks/, or an audit's findings indocs/audits/, one Markdown file each. - Give each file the frontmatter fields for its type (below).
- Push. The folder's
README.mdnow has a generated list below it.
No .stele.yml is needed for those three folders. For any other folder, add one line to its .stele.yml:
# decisions/.stele.yml
collections: adr
The three types
Stele has three built-in types. You cannot define your own; each type decides which fields it reads, what its statuses mean and what its list shows.
| ADR | Runbook | Audit finding | |
|---|---|---|---|
Name in collections: |
adr |
runbook |
finding |
| Conventional folder | docs/adr/ |
docs/runbooks/ |
docs/audits/ |
| Standard fields | title, status, date, decision-makers, consulted, informed |
title, owner, last_verified, verification_method, alert |
title, status, severity |
| Status values | accepted, proposed, superseded, deprecated, rejected |
none | open, in-progress, deferred, resolved, accepted-risk |
| Other values | — | — | severity: critical, high, medium, low |
| Relations you write | supersedes, related |
related |
related, duplicates |
| Columns in the list | #, Title, Status, Date | Title, Owner, Last verified (and Alerts, with an alerts file) | Title, Severity, Status |
| Summary above the list | counts by status | — | counts by severity and status, and how many are still to act on |
A few rules hold for every type:
- The title is the frontmatter
title, then the file's first#heading, then its file name, the same rule as every other page. - Status and severity are matched loosely: case, spaces, hyphens and underscores are ignored, so
In Progressreads asin-progress. An ADR status that starts withsuperseded byreads assuperseded. A value Stele does not know is shown exactly as you wrote it. - An ADR with no
status:in its frontmatter is read from its body instead: the first paragraph under a## Statusheading, as adr-tools and MADR 2 write it. Two forms are understood: a bare word (Accepted, or adr-tools'Superseded by [2. Title](0002-title.md)), and the word followed by a sentence (Accepted. Confirmed with the platform team on 2024-03-01.). Only the word is read; a link or number after it is never a relation, so writesupersedes:for that. A single unknown word is shown as written, with a warning; a paragraph that does not start with a known status gets no status and a warning. Frontmatter always wins, even an emptystatus:. The section has to be in the first 16 KiB of the file, where these tools put it. - Any other field is kept with the item as an extra property, and Stele does not interpret it. That includes
deciders(what older MADR versions calldecision-makers) and atype:field: the folder decides an item's type, never its frontmatter. - Runbooks are stale when
last_verifiedis more than 180 days old; the list marks them Stale. ADRs and audit findings have no freshness: a decision is superseded, not re-verified.
An ADR
---
title: Use Postgres for collection records
status: accepted
date: 2026-10-07
decision-makers: [hw]
supersedes: [0003-store-records-in-s3]
---
# Use Postgres for collection records
## Context and problem statement
...
A runbook
---
title: Queue backlog
owner: platform-team
last_verified: 2026-09-30
verification_method: game day
---
# Queue backlog
## Trigger
...
An audit finding
---
title: Admin panel has no rate limit
status: open
severity: high
related: [auth/0004-session-fixation]
---
# Admin panel has no rate limit
...
Which folders are collections
A folder is a collection because of where it is or because its .stele.yml says so, never because of what its files are named.
docs/adr/,docs/runbooks/anddocs/audits/at the repository root are collections of their type with no configuration, from the first Markdown file.- Any other folder becomes one with
collections:in its.stele.yml. A folder calledadrsomewhere else, or files named0001-*.md, are not enough. - A collection covers its subfolders too. Items in
docs/audits/network/belong to thedocs/audits/collection and are listed under their subfolder's name. - One folder holds one type.
collections: takes three forms:
# A collection with every default
collections: runbook
# The same, changing only what differs
collections:
kind: adr # required: adr, runbook or finding
append: false # don't add the list below this folder's README
status_field: state # read each item's status from `state:` instead of `status:`
# Not a collection, even at a conventional location
collections: none
none turns the folder and its subfolders off; a subfolder can turn itself back on with a setting of its own. collections: none in the root .stele.yml turns every conventional folder off. The full reference, including what happens to a mistake in this setting, is in Configuration.
Where the list appears
The list goes on the collection folder's landing page, the page Stele shows for the folder's own URL (see Landing page resolution):
- Below a Markdown landing, such as the folder's
README.mdorindex.md. Your page is rendered as you wrote it and the list follows it. - As the landing, when the folder has none. A folder that used to give a 404 now shows its list.
- Never on an HTML, TSX or JSX landing. An authored
index.htmlalways wins, and is served exactly as written.
If your README already lists its items by hand and you don't want a second list, set append: false:
# docs/adr/.stele.yml
collections:
kind: adr
append: false
Under the list, a line reads "Listed by Stele from N files in this folder." That is the reader's cue that the list is generated, so nobody goes looking for it in the README. The file browser is unchanged and still shows every file.
Nothing is hidden. Items are ordered by status: for ADRs, accepted and proposed come first, and superseded, deprecated and rejected follow, dimmed. For audit findings, open and in-progress come first, then deferred (not dimmed, since deferring is about priority, not being done), then resolved and accepted-risk, dimmed. A list of six or more items has a filter by title and by status.
Each viewer sees only the items they could open. If a subfolder has a narrower access:, its items are listed only to people allowed into it.
Each item's own page gets a line under its title that leads back to the list: the collection's name as a link (ADRs, Runbooks, Audit findings), then the item's status and the dates its list shows, such as an ADR's date. Relations and a runbook's alerts follow on the same line, as described below. A runbook's last_verified is not repeated there; the page's metadata strip already shows it as an age.
The # column
An ADR list starts with a # column holding the leading digits of the file's name: 0029 for 0029-use-postgres.md, blank when the name has none. It is there for reading only. The number is not the item's id, does not order the list, and two files with the same number after a merge are both listed.
The collection's README
The README.md at the top of a collection is never an item. Its body is the collection's introduction: as the folder's landing, it is rendered as usual with the list below it. For audit findings, its frontmatter also holds the audit's own details, shown between the README and the list:
---
audited_on: 2026-09-15
scope: Public web app and admin panel
must_do:
- 0002-admin-panel-has-no-rate-limit
- auth/0004-session-fixation
---
# Security audit, September 2026
Executive summary...
audited_on— when the audit took place.scope— what it covered.must_do— the findings that must be acted on, by item id, each shown with its current status.
ADR and runbook collections have no standard README fields; their README body is the summary. A README.md in a subfolder of a collection introduces that group, with the subfolder's items listed below it.
The counts above the list are worked out from the items on every sync. Don't write them by hand; they would go stale on the next commit.
Relations between items
Items can point at other items in the same collection. Write the relation on one side only, as a list of item ids, and Stele works out the other side.
An item's id is its path inside the collection folder, without .md: 0007-use-postgres, or network/0003-open-ports for a file in a subfolder. A single id without a list is fine, and a leading ./ or a trailing .md is tolerated.
| Type | You write | Stele also shows, on the other item |
|---|---|---|
| ADR | supersedes: [<id>] on the newer record |
Superseded by on the older one |
| ADR, runbook, audit finding | related: [<id>] |
Related, both ways |
| Audit finding | duplicates: [<id>] |
Duplicated by |
# docs/adr/0007-use-postgres.md
supersedes: [0003-store-records-in-s3]
With that one line, 0003 is listed as superseded whatever its own status: says, and its page carries a line at the top reading Superseded by with a link to 0007. Every item page shows its relations both ways in that line, so a reader can follow a chain of decisions in either direction. Don't write superseded_by or duplicated_by yourself: what Stele works out is what it shows. Do set the older record's own status: superseded as well; if it still says accepted, Stele lists it as superseded anyway and notes the disagreement as a warning.
Relations work only inside one collection. An audit finding cannot point at an ADR.
Runbooks and alerts
If your repository is on call, it may keep an alerts file: a JSON array with one entry per alert, naming the runbook that answers it.
[
{
"name": "QueueBacklogHigh",
"runbook": "docs/runbooks/queue-backlog.md",
"condition": "queue_depth > 1000 for 10m"
}
]
Tell Stele where it is with alerts_file in .docs/repo.yml, with the path from the repository root:
# .docs/repo.yml
alerts_file: alerts/alerts.json
Stele then shows:
- the alert's name and condition next to each runbook in the list;
- a line at the top of the runbook's page: Triggered by
QueueBacklogHigh(queue_depth > 1000 for 10m); - the gaps both ways: alerts whose runbook file is missing, and runbooks no alert names.
Stele reads .docs/repo.yml and the alerts file at sync even though .docs/ is hidden, and never publishes either as a page. With no alerts_file, Stele does not go looking for one. A runbook's own alert: field is shown as written; only the alerts file is joined to the runbooks.
Picking a landing with frontmatter
Any Markdown file can make itself its folder's landing page with a flag in its frontmatter. Both spellings work:
---
stele.landing: true
---
---
stele:
landing: true
---
Only true counts; "true", yes and 1 do not. The flag is the last thing Stele tries: a landing: in .stele.yml, an index.* file or a README.md in the same folder wins over it. In a collection, a flagged Markdown landing gets the list below it like any other Markdown landing.
What Stele never does
Collections show what is in your files. They never judge them.
- Nothing stops a page rendering. An unknown status, a relation to an item that does not exist, a duplicate number, a missing runbook or a mistake in
collections:is a warning for the site's owners. Every page still publishes. - No enforcement. No pass or fail, no "invalid" badge, no required sections, files or fields. Checking documents against a standard is the job of that standard's own tooling and your CI.
- No editing. Stele has no "new ADR" button and never writes to your repository.
- No shadowing. An HTML, TSX or JSX landing is always served as you wrote it, and a Markdown landing is kept with the list added below.
.docs/repo.ymlnever changes who can read the site. Itsvisibilityfield included.