How documentation stays alive
Documentation doesn't die from a lack of initial writing. It dies from drift: the code changes, the doc stays, and one day someone notices it describes a system that no longer exists. From that point on nobody trusts any page, including the ones that are right.
This page explains the mechanism that exists to prevent that. Read it if CI complained about you — and read it before turning off any part of it.
The principle: generate > verify > remember
Three levels, in decreasing order of reliability:
| level | how it works | when to use it |
|---|---|---|
| Generate | the doc comes out of the code; drift is impossible | the list IS the content |
| Verify | the doc is hand-written, CI checks that it's complete | the prose matters more than the list |
| Remember | CI warns whoever opened the PR | when human judgment is required |
The last level is the weakest, and that's why it's the last resort. A warning nobody reads protects nothing.
A number in prose is a verify case
A number in the middle of a sentence — "the 30 ADRs," "next is
0031" — isn't generatable: it lives inside the text, and replacing
it with a placeholder would make the source prose unreadable. But it's
verifiable, and that's where it should live.
Without that, it ages silently. That's what happened: the published site said "28 of them" and "the 29 decisions" when there were already 30, and "next is 0030" with 0030 already done. Nothing broke, no check complained — it just went wrong.
The list of verified numbers grew after a third case proved the same point. An external review found the README announcing "the 158 RNs" when 331 were written — wrong by more than double, in the table that introduces the repository. It was corrected by hand (PR #412), which only resets the clock: the next RN would put it back out of date. So the check stopped covering ADR counts alone and now covers three families, each read from the ARTIFACT and never from other prose:
| number in prose | source of truth |
|---|---|
| ADR count, next ADR number | the files in docs/adr/ |
| RN count | the ### RN-NNN headings in business-rules.md |
| LLM provider count | the capabilities literals descobrirProviders() finds — the same source as the generated table in llm-providers.md |
The provider one is written in words ("nove providers"), and the check compares against the word rather than forcing the prose to become a digit: the check exists to protect the text, not to reshape it.
Since AT-399 (2026-10-03) these numbers are also rewritten. Parallel
PRs that each add an RN or ADR wrote the shared numbers by hand, and every
merge left the others stale: the docs guardian failed 8 of 30 runs in three
hours for that reason alone. So pnpm docs:generate (write mode) now fixes
in place what the source determines — the ADR and RN counts above, the
next ADR number (dropping a duplicate "the next one is" line left by a
merge), the indentation of docs/adr/temas.yml entries written at column 0,
and the line of a symbol reference (below). The pure functions live in
scripts/docs/reescrita.mjs, refs-com-simbolo.mjs (novaLinha,
reancorar) and temas-de-adr.mjs (corrigirRecuo). --check keeps the
same severity: a stale committed file still fails CI, and the fix is
pnpm docs:generate plus a commit, as with generated files. What still
needs a human: a phrase whose pattern stopped matching (CEGO), an ADR
without a theme, a variable without a description, and a reference whose
symbol left the file, moved more than 40 lines, or ties between two nearest
occurrences.
The most expensive case was the version announced in prose: the
README announced v0.1.0 from Phase 5 all the way to v2.1.0 — seven
releases behind reality, in the first thing a newcomer reads. The
check compares the prose against the first ## vX.Y.Z in the
CHANGELOG, which is written by the release workflow and comes back via
PR; a tag read from git wouldn't work, because the CI's shallow
checkout might not have any tag at all. The badge, on the other
hand, left the check and became generation: it reads the GitHub
release directly (shields.io/github/v/release) and updates itself —
you only verify what can't be generated.
And it happened once per entry point. docs/intro.md — the
published site's first page, which not everyone reaches through the
README — kept saying "Phases 1 to 5 complete, v0.1.0" while the
product was at Phase 26. Checking only the README never protected the
second door, so verificarVersaoAnunciada received both files in a
list, and scripts/ci/readme-version.ts started writing to both in
the same commit as the CHANGELOG. The two halves move together on
purpose: a check that demands what the generator doesn't write would
make every release be born red on a PR the bot opens and nobody can
fix.
The intro.md pattern requires the whole sentence — "Phases 1
through NN complete, version vX.Y.Z" — not just the version: the two
halves age together, and matching only half would leave the phase
count lying next to a correct version. The phase count isn't
generatable from here: the release knows the version, not the phase.
generate.mjs now checks these claims against the directory's
reality. And a pattern that stops matching also fails: a check
whose regex stopped finding the sentence is worse than no check at
all, because it stays green forever claiming to have checked something
it never looked at. When the sentence changes, CI says CEGO and
asks for the pattern to be adjusted.
Adding a new claim to the check is an entry in one of the afericoes
lists — verificarContagensDeAdr (file, pattern, expected value) when
the source of truth is the ADR directory, verificarVersaoAnunciada
when it's the CHANGELOG's latest release — or a function of its own
next to them, when it's neither.
Counts derived from code (AT-123). A sweep on 2026-09-25 of
docs/, README.md, CONTRIBUTING.md, THIRD_PARTY_NOTICES.md and
CLAUDE.md found five numbers already wrong — "nove schemas" (eleven),
"doze operações" of the git contract (fifteen), "dez abas" (twelve),
"três imagens" built by CI (five), "Thirteen types" of
proposed_action (twenty-one) — none with a check behind it. The
counts whose source is a place in the code now live in
scripts/docs/contagens-do-codigo.mjs, called by
verificarContagensDerivadasDoCodigo: one table row per SENTENCE
(the same count said in two files is two sentences that age apart),
each pointing at an extractor that counts the artifact — the
overrides blocks of the two pnpm-workspace.yaml, the golden-set's
CASOS and ARQUIVOS_CURADOS and its floor.json, the total that
scripts/ci/imagens-pinadas.ts prints, ALVOS, the smoke's non-root
loop, the methods of GitProviderContract and of DockerPort, the
tab REGISTRO, the dev compose services without profiles: (which
must ALL have a healthcheck, or the expected value becomes a sentence
the prose doesn't have), ACTION_TYPES, the action_status enum and
the pgTable( calls. Comparison ignores case and follows the
sentence's form (Portuguese masculine/feminine words, English words,
or digits). Because the extractors are many, each is proven by
MUTATION in contagens-do-codigo.spec.ts: change the artifact and not
the prose, and it must fail DESATUAL; remove the sentence or the
source, and it must fail CEGO.
What was dated instead of derived, and why: the per-app file counts
in architecture.md and the broker's "six routes" change with almost
every PR or are read off a switch, so a check would only add noise —
they carry "measured 2026-09-25". Numbers that describe a design
invariant ("two layers", "three states") or a past measurement already
dated in place (THIRD_PARTY_NOTICES.md, the runbook's restore
transcripts) were left as they are.
Line references with a symbol
An RN cites code by path:line, and a line number goes stale every
time someone edits the file. RN-547 cited fechar_a_instalacao at
install.sh:966 and post_interno at :682. By 2026-09-17 they were
at :1107 and :809, and by 2026-09-18 at :1463 and :1165. A line
number can't be generated, and in general it can't be verified either,
because apps/api/src/foo.ts:40 alone doesn't say what should be
there. When the RN also names the symbol next to the line, though,
the line can be verified. That's the only case generate.mjs checks
(verificarRefsComSimbolo, with its logic in
scripts/docs/refs-com-simbolo.mjs, tested).
The pattern is narrow on purpose. A noisy check gets switched off in
its first month. It reads the three RN files (business-rules.md,
business-rules/custo.md, business-rules/autenticacao.md) and takes
only these two forms:
`<path>:<N>` (`<symbol>` explicit reference
`:<N>` (`<symbol>` continuation — inherits the path
- The symbol has to come right after the reference, opening a parenthesis and in backticks. Only whitespace may sit between them, including a line break.
- The symbol has to be an identifier:
fechar_a_instalacao,join/3,git_dir?,Engine.Runners.RunnerReadiness,decide().MARCADOR_SCHEMA=3, a phrase or a path is not a symbol, and gets skipped. - Ambiguous pairs are skipped:
:640/:647(a/b), or two references before one symbol. The check can't know which symbol goes with which line. - A continuation inherits the last explicit path in the same list
item. A blank line or a new
-ends the item. - The path resolves from the repo root. If it isn't there, the check uses the single tracked file whose path ends with it. If no file matches, or more than one does, the reference is counted apart as unresolved.
A reference matches when the symbol, or its last . segment, shows
up as a whole word within ±3 lines of N. The window allows for a
citation that points at the docblock rather than the signature. It stays
small because drift moves by dozens or hundreds of lines. When a
reference doesn't match, the output names the nearest line where the
symbol does appear. Since AT-399, pnpm docs:generate rewrites N to
that line when the nearest occurrence is unique (no tie at the same
distance) and at most 40 lines away (DISTANCIA_MAXIMA_DE_REANCORA);
when the symbol is gone from the file, moved further, or two occurrences tie,
the reference is left alone and must be re-read by symbol by hand. The
nearest occurrence may be a call rather than the definition — that is the
price accepted for not hand-editing numbers across parallel PRs, and the
diff of the regenerated file shows each move for review.
Measured on 2026-09-18, over every path:N in the three files:
| count | |
|---|---|
…:N references in total | 592 |
| match the pattern | 187 |
| correct (within ±3) | 116 |
| wrong | 71 |
| unresolved path | 0 |
A spot check of the 71 found no false positives: each reference it
covered was real drift, a rename, or a symbol that moved to another
file. Widening the window to ±5 would clear only three of them. Nine are
between 4 and 10 lines off, and the other 62 are further. The RN-547 code bullet was
re-read by symbol and fixed in the same PR, which leaves 63. They
are too many to fix one by one inside this change, and the nearest
occurrence is not always the definition. The list is what pnpm docs:check prints.
Severity at birth: warn. It reported and didn't fail, because a
block would have stopped every PR that touched an RN file over debt
someone else left. There was one exception, the house rule: if the check
extracts zero references, that is CEGO and fails. Zero means
the RN syntax changed or the extractor broke, and a blind check stays
green forever.
The original criterion for block (PR #593): once the list is empty, and
after four consecutive weeks of docs:check on dev with no new
wrong references in RNs touched during those weeks. At that point every
new wrong reference is the current PR's fault, and the PR can fix it.
Promote it earlier and it charges each PR for someone else's debt. If
the pattern shows a real false positive before then, narrow the
pattern. Don't widen the window.
The list went empty on 2026-09-26 (AT-122). In the eight days after
the check landed, the 63 known-wrong references had grown to 75. That
growth is the drift the check exists to show. All 75 were re-read by
symbol against the code of that day, and none was a false positive of
the pattern. Most had drifted by dozens of lines. One was a rename:
modo_de_execucao/1 became exigencias_do_projeto/1 in RN-516. One had
moved to another file: escreverEnv went from preflight.mjs to
env-file.mjs. Two pointed at the right test, but at the it( line,
while the symbol (types) only shows up a few lines below, in the body.
Those two now point at the body line. The window stayed at ±3. The
bullets with the worst drift (RN-566, RN-567, RN-570 and RN-514) had
their unchecked neighbouring references re-read in the same pass. After
that, 273 references match the pattern, 273 are correct, and 0 are
wrong. The four weeks were to start when that change reached dev.
Promoted to block on 2026-10-01 (AT-122), by the maintainer's
decision, without waiting out the four weeks. Five days after the list
went empty, dev had 140 wrong references again, out of 896 that
match the pattern: the warn didn't hold the drift back, because a
warning nobody has to act on is a warning nobody reads. The 140 were
re-read by symbol in the same change that promoted the check, never by
adding a delta, and the window stayed at ±3. None was a false positive
of the pattern, and no symbol was missing from its file. What the pass
found besides line drift:
- Merge leftovers. Three bullets had been duplicated by conflict
resolution, one copy with older numbers next to one with newer ones
(RN-120's
Where, RN-547'sCódigoand RN-626'sSessionPage.tsxline). The stale copy was removed, which is why the total went from 896 to 883. - One reference whose subject changed (state drift, not line drift).
RN-587 cited the test for an
inframessage; since RN-617 the Infra Lead converses, and the test now usesqaas the roster agent without a conversation. The reference now says so. The RN's rule did not change. - Unchecked neighbours in the bullets with the worst drift (RN-547, RN-567, RN-587, RN-609, RN-663, RN-566) were re-read in the same pass where the test or clause they name could be identified.
After that, 883 references match the pattern, 883 are correct, and 0 are wrong.
Severity now: block. A reference that doesn't match, or whose
path no longer resolves to a single tracked file, fails docs:check.
Both are the same drift: the RN points where the code isn't. Zero
references is still CEGO and still fails. The verdict is veredito in
refs-com-simbolo.mjs, proved by mutation in the spec next to it: a
reference moved out of the window fails, a symbol that left the file
fails, a path that no longer resolves fails, and text rewritten until
extraction yields zero is CEGO, never ok. When it fails, fix the
reference by the symbol (the output names the nearest line where it
appears, which is a hint, not a fix). Don't widen the window, and don't
narrow the pattern to make a real reference disappear; narrow it only
for a real false positive, with the case in the spec.
The pt-BR translations of the three RN files under website/i18n/ are
not checked. They carry few references (23 match the pattern, 15 of
them wrong on 2026-10-01), and the translation is behind the English
source anyway; checking a copy nobody keeps in step would fail every PR
over the translation's lag.
Every RN heading carries its anchor
The {#rn-NNN} anchor on an RN heading is the contract for links from
outside: it survives a retitled RN and an RN moved to another file. On
2026-09-26 (AT-230) dev had 451 ### RN- headings and 449
anchors. RN-305 and RN-306 had been written without one, in
business-rules.md and in its pt-BR translation, and nothing failed.
The site build doesn't catch it: without an explicit anchor Docusaurus
derives the id from the heading text and the page compiles. What breaks
is a #rn-305 link someone writes later. The RN count doesn't catch it
either, because it counts headings, not anchors.
generate.mjs now checks it (verificarAncorasDeRn, with its logic in
scripts/docs/ancoras-de-rn.mjs, proven by mutation in
ancoras-de-rn.spec.ts). Every heading, at any level, that starts
with RN-<digits> has to end in {#rn-<same digits>}, leading
zeros included (RN-001 → rn-001). A missing anchor and an anchor
with another number or name both fail, one line per heading, with the
file and line. Zero headings found is CEGO and also fails.
Severity: block, unlike the line references above: there is no
inherited debt (the two missing anchors were added in the same change),
and each new heading is written by the PR that adds it.
The translations are covered too. The files are found by glob:
docs/business-rules.md, docs/business-rules/*.md (the pt-BR source,
ADR 0216), and the same two paths under
website/i18n/<locale>/docusaurus-plugin-content-docs/current/ for each
translated locale in LOCALES_TRADUZIDOS (en, ru, zh-Hans). A
locale's site serves business-rules#rn-305 from the translated file,
and an earlier translation had copied the heading without its anchor. The
rule is per heading, so a partial translation isn't a problem: an RN that
wasn't translated falls back to the pt-BR source, which is checked
anyway.
The runbook's procedures table
The runbook ends with a table of its operation procedures —
procedure | anchor | verification | schedule — saying what proves each one
and what runs that proof
(Procedures and how each is verified).
It exists because of EP-015's closing criterion: no row with an empty
verification or one that says "see below", and the proofs that exist run on a
schedule, not from memory. Measured on 2026-09-25 (AT-129), the table the
criterion talked about did not exist in the repository: a seven-row copy lived
in the maintainer's notes, against 28 operation procedures here, and one row
pointed at another note. A hand-written table of file names ages the same way
prose numbers do — a spec gets renamed, a workflow loses its schedule:, and
the cell keeps claiming.
generate.mjs checks it (verificarProcedimentosDoRunbook, logic in
scripts/docs/procedimentos-do-runbook.mjs, proven by mutation in
procedimentos-do-runbook.spec.ts). Per row, it fails when:
- the anchor cell has no
(#id)link, or the id is not an explicit{#id}on a heading of the runbook — the same contract as the RN anchors: a retitled section must not break the link; - the verification cell is empty, says "see below/above", names a file
that is not tracked by git or a
maketarget theMakefiledoes not have, or names nothing checkable and does not start with**None**— the explicit statement that no proof exists; - a schedule segment (they are separated by
;) does not start with its trigger class —weekly/daily/monthly,every PR,every tagormanual— or a workflow it cites is missing or does not have that trigger in its parsedon:(schedule:,pull_request,push: tags). Amanualsegment may not cite a workflow.
The column is called schedule and still accepts every PR and every tag
on purpose. The criterion's question is "does it run without someone
remembering?", and a spec that ci.yml runs on every pull request answers
yes; calling it manual would be wrong in the other direction. What the check
guarantees is that the class written is the one the workflow has: weekly
pointing at a workflow with no schedule: fails, which is the case AT-193
named.
A missing section, a missing table, changed column headers or zero rows is
CEGO and fails.
Severity: block, for the same reason as the RN anchors: the table was
born with every row checked, so there is no inherited debt, and whatever
breaks it later (a renamed spec, a workflow that lost its schedule) is the
fault of the PR that broke it, which is also the PR that can fix it.
Only the source runbook (docs/runbook.md) carries the table. Its old
pt-BR copy was behind (AT-209) and left with ADR 0216; a future translation
under website/i18n/<locale>/ falls under the traducao docmap rule
(warn) and would not carry the ten explicit ids this table added. A
second, unchecked copy of the table there would be exactly the copy the table
exists to replace.
The ADR index is grouped by theme
The ADR index used to be grouped by phase. Once the phases ended, the last
section became the index: measured on 2026-10-01, 148 of its 191 rows lived
under ## Phase 12. ADR 0202
groups it by theme instead, and puts the theme outside the ADR — an
accepted ADR is never edited, not even to gain a tema: frontmatter key.
The theme lives in docs/adr/temas.yml, next to the index: temas: is the
closed, ordered list (id, titulo, descricao) and adrs: maps each ADR
number to ONE theme. The index is checked, not generated — its one-line
descriptions are curated and no script writes them, the same reason
verificarIndiceAdr gives — and each theme is a section
## <titulo> {#tema-<id>}, in the list's order, with its rows in numeric
order.
generate.mjs checks it (verificarTemasDeAdr, right after
verificarIndiceAdr; logic in scripts/docs/temas-de-adr.mjs, proven by
mutation in temas-de-adr.spec.ts). It fails an ADR file with no theme, a
theme in the map that is not in the list, a map entry for an ADR that does not
exist, a listed theme with no ADR, a repeated or malformed theme id or title,
and, in the index, a missing, repeated, misnamed, out-of-order or unknown
theme section, or an ADR row under another theme's section, outside any theme
section, repeated or out of numeric order. Unreadable YAML, an empty list or
map, or an index with no theme section at all (the old by-phase index) is
CEGO and fails.
Severity: block, like the runbook table: it was born with every ADR
classified, so whatever breaks it later is a new ADR whose PR forgot its line
in temas.yml — the PR that can fix it. Translations of the index live under
website/i18n/<locale>/ and are not checked; the traducao rule warns, as
always.
The pieces
docs/.docmap.yml — the map
Links code paths to the documents that depend on them. It's the only source that says "whoever touches this needs to review that."
Two severities: block fails the PR, warn only comments. And a
generated: true attribute, which marks the documents that come out
of the generator.
A rule that charges for what makes no sense teaches people to reach for
the escape hatch by reflex. That is why politica-de-branches watches an
allow-list in scripts/ci/ — the scripts that are the branch policy
and their specs — instead of scripts/ci/** minus a hand-kept list of
exclusions, which every new repository-wide spec in that folder had to join
(eleven by AT-206). The allow-list does not give up failing closed:
scripts/docs/politica-de-branches.spec.ts derives the set from the
repository — every scripts/ci/ file whose opening docblock cites
branching-policy.md, plus its spec — and fails if the rule diverges. A new
policy script declares its source in its header, like the others, and the
spec does the rest.
docmap.mjs — validates the map
Runs before everything else, because a broken map makes the rest lie. It fails if:
- a glob matches no file — a dead rule. It never fires, and gives the impression of coverage that doesn't exist. This is the most silent defect a docmap can have, and it was found in 8 globs when the validator was introduced.
- a document the map points to doesn't exist
- there's a duplicate id or an invalid
severity
generate.mjs — generates and verifies
Two output modes:
Whole file — docs/reference/scripts.md. There's no prose to
preserve: the list of commands is the content. It comes from each
package's package.json and the Makefile's annotated targets. The
package list is declared in gerarScripts, including the two packages
outside the workspace (website/, e2e/, run with --dir), and a
package.json that is missing or doesn't parse fails generation by
name — it used to drop the package silently (AT-224).
Marked block — the stretch between <!-- BEGIN:GENERATED:<id> -->
and <!-- END:GENERATED:<id> --> inside a hand-written file. That's
the case for configuration.md and events.md: there, the prose
("what breaks when this variable is wrong") is worth much more than
the list, but the list needs to be complete. The block is the
inventory; the surrounding text is the explanation.
The inventory marks with ⚠️ whatever shows up in the code and has
no description in the prose. That's how tool.result and
agent.response — two real event types — showed up after being left
out of the first draft.
What the environment inventory catches, measured (AT-124,
2026-09-25). The sources and their globs live in
scripts/docs/fontes-de-env.mjs, proven against the real tree by
fontes-de-env.spec.ts. A mutation per source — a new
process.env.X in a file straight under e2e/, nested in
e2e/suporte/, straight under apps/api/scripts/ and nested one level
below it, each git added — made --check fail with DESATUAL on
configuration.md in all four. One hole was found and closed: the
.spec. filter, right for sources where a spec is a unit test sitting
next to the code, was also dropping e2e/testes/*.spec.ts — the
Playwright tests themselves, where a new e2e/ variable is most likely
to be born — and that mutation passed green. One limit stays declared:
the inventory reads only versioned files (git ls-files), so an
untracked file is invisible until git add.
deploy/k8s/*.sh became a source (AT-212, 2026-09-28). The bootstrap
and the property proofs read twenty-odd variables from the environment —
cluster name, namespace, the smoke account, each proof's ceilings — and none
was in the inventory. They are a ferramenta source, matched by the shell's
expansion with a default (${X:-…}, ${X:=…}, ${X:?…}) and only by it:
a bare ${X} is almost always the script's own variable. Mutation: a new
${X:-x} in deploy/k8s/validate.sh, staged, made --check fail with
DESATUAL and a SEM DESC. line naming the variable and the file.
The environment ⚠️ is a gate (AT-211, 2026-09-27). Until then what
failed was only the stale block: once docs:generate rewrote it,
the variable sat there with ⚠️ and --check passed —
HUGGINGFACE_API_TOKEN and HUGGINGFACE_HUB_URL stayed that way for
months. By the maintainer's decision, --check now fails on any
⚠️ in the environment inventory, for both kinds of source
(produto and ferramenta), even with the block up to date. The
failure names the variable, the file that reads it and the section of
configuration.md where the description goes (SEM DESC. lines). The
rule lives in scripts/docs/inventario-de-env.mjs and is proven by
mutation in inventario-de-env.spec.ts: a new variable without a
description fails, the same one with a description passes.
A **TODO(humano):** on the line that cites the variable counts as a
description and doesn't fail: the docs rule is "never invent; without
information, TODO(humano)", and a gate that failed the TODO would push
whoever doesn't know what the variable does into inventing a sentence
to get green. The TODO is a declared gap, with the question written
where the answer goes; the ⚠️ is a silent one. --check still lists
variables described only by a TODO, on a TODO line, without failing.
The event inventory in events.md keeps its ⚠️ as a visible gap — the
decision covered the environment inventory only.
--check writes nothing and fails if anything would be different.
That's CI's mode.
drift.mjs — enforces it in the PR
Cross-references git diff --name-only <base>...HEAD with the map.
For every triggered rule whose document wasn't touched: block fails
it, warn comments.
For a Dependabot PR whose diff is only action-pin bumps, a step just before the
drift (scripts/ci/dependabot-justifica-pin.ts) writes the docs-not-needed:
line into the body, in the same job so it writes and reads in one execution.
The workflow listens to edited, and @dependabot rebase emits synchronize
and edited together; concurrency cancels one, so the surviving run may be
the edited one. Hence the step also evaluates on edited when the editor is
the bot (AT-100), and never when it is a human (their deletion of the line
wins). It can't loop: body edits made with the workflow token don't fire
edited. Failure stays closed: if the write fails, the drift reads the event
body and the PR stays blocked.
audit.mjs — the monthly audit
The drift check catches a doc that went wrong in a PR. The audit catches a doc that went stale with nobody touching it — which is harder to notice. It reports:
- pages untouched for months whose corresponding code changed since
- pending
TODO(human)items file:linereferences that no longer resolve- ADRs in
proposedstatus for more than 60 days
Always in the same issue, updated in place. A new issue every month becomes spam, and spam gets turned off.
The site build
onBrokenLinks, onBrokenAnchors and onBrokenMarkdownLinks are set
to throw. Moving a file without fixing whoever points to it breaks
CI instead of turning into a 404 in production. It's the cheapest
mechanism in the whole set.
A new page needs to be added to sidebars.ts by hand
The Markdown content lives in docs/, but the routing lives in
website/sidebars.ts — and its sections enumerate items one by one,
instead of scanning the directory. Creating a file in docs/ without
adding it there produces a page that exists, is served by direct URL,
and doesn't show up in navigation.
The build doesn't fail on this: an orphan page isn't a broken
link. It's a visual check, and it's the only step in the mechanism
with no safety net — after pnpm docs:build, open the sidebar and
confirm the page is there.
The exception is the ADRs: the "Decisões (ADR)" group is derived on
every build from docs/adr/temas.yml and the files in docs/adr/ — one
subcategory per theme, in the list's order, with the ADRs in numeric order
(AT-560). A new ADR lands in its theme's group without touching
sidebars.ts. If the parser finds no themes, no mapped ADRs, or an ADR
without a theme (which docs:check already rejects), the build fails with a
named error instead of drawing a wrong sidebar. The Explanation section is
listed by hand in five groups by subject, and a new page there still goes in
by the path above.
api-render-check.mjs — a green build isn't a page that renders
This piece exists because of an expensive lesson: the API
reference's 117 operation pages went live dead in releases v1.0.0
and v1.0.1, and no check caught it.
The Docusaurus config didn't declare docItemComponent: '@theme/ApiItem', so Docusaurus used the default @theme/DocItem.
ApiItem is the only place in docusaurus-theme-openapi-docs that
mounts redux's <Provider>, and the @theme/ApiExplorer/MethodEndpoint
each .api.mdx imports reads that store with useSelector. Without
the wrapper, the context is null — and the error boundary swapped the
whole page for "This page crashed."
The failure mode is what matters here, because it defeats every other piece of this mechanism:
| stage | result |
|---|---|
| MDX compiles | ✅ theme components exist and resolve |
| SSR renders | ✅ the served HTML has the route's content |
pnpm docs:build | ✅ green |
| hydration in the browser | ❌ the page gets wiped |
In other words: "the build passed" was never proof the page works.
Since ADR 0206 that plugin is gone: the reference is one Redoc page
(redocusaurus, server-side rendered) at /reference/api/, and there is
no ApiItem to forget. The lesson stays, so the guard stays too:
api-render-check.mjs runs after docs:build and asserts, in both
locales, that reference/api.html exists, carries the spec's
info.title, and contains one server-rendered section per operation in
docs/reference/openapi.json (Redoc's
data-section-id=tag/<tag>/operation/<id>).
It catches the page vanishing, rendering empty or losing operations — not a hydration failure, which would need a headless browser.
The same episode produced the map's site-e-publicacao rule:
website/** wasn't covered by any rule, and changing the site config
didn't demand documentation.
Four locales, with pt-BR as the source (ADR 0216)
docs/ is the pt-BR tree, the source and the default locale
(defaultLocale: 'pt-BR', served at the root of each environment). The
translated locales are en, ru and zh-Hans, under
website/i18n/<locale>/docusaurus-plugin-content-docs/current/, with their
theme labels in website/i18n/<locale>/docusaurus-theme-classic/ and the
sidebar labels in .../docusaurus-plugin-content-docs/current.json. A page
with no translation in a locale is served from the pt-BR source at the same
slug. The local search indexes pt, en, ru and zh. Pages whose old
pt-BR copy was out of date stay in docs/ in the language they are in
until their batch.
The English site moved from the root to /brabo/<environment>/en/…, and old
links to /brabo/<environment>/pt-BR/… are rewritten by the root 404.html
(scripts/docs/landing.mjs) to /brabo/<environment>/…, in the same mold
as /main/ → /prd/ (ADR 0073).
The docmap rule traducao (it was traducao-pt-br) watches the source
and points at website/i18n/. It is warn and never block: requiring a
translation in every doc PR would cost three translations per change.
Translation parity is generated, and the page says when it is behind (AT-550)
What holds the lag back is measurement, not the docmap.
scripts/docs/paridade-de-traducao.mjs (proved by mutation in the spec next
to it) runs inside pnpm docs:generate and writes two files:
docs/reference/paridade-de-traducao.md (generated: true) and
website/src/data/traducao.json. Per locale it counts pages and characters
translated over the source (all of docs/, reference/ included since ADR 0219, minus the report itself and openapi.json), and splits the
translated pages into up to date, stale and without provenance.
Provenance is the traducao_de: <SHA> in the translated page's frontmatter,
the last source commit it translates. A page is stale when the source has a
commit with a later COMMIT DATE than that SHA, when the source has
uncommitted changes, or when the SHA does not exist. It never uses the clock,
so --check gives the same answer before and after the commit that touches
the source. For the UI it compares the keys of each namespace in
apps/web/src/locales/<l>/ with pt-BR, and requires every plural form that
Intl.PluralRules gives that locale. --check fails when either file is out
of date.
The banner is a wrap swizzle of @theme/DocItem/Content
(website/src/theme/DocItem/Content/index.tsx). Outside pt-BR, a page whose
metadata.source is under @site/docs/ is the pt-BR fallback and says so;
a translated page that traducao.json marks stale shows the translation
date and says the original changed. The texts live in each locale's
code.json. scripts/docs/faixa-de-traducao-check.mjs runs after
pnpm docs:build and checks the static HTML: a fallback page has the banner
(the sample comes from the first locale, en, ru or zh-Hans, that still has
an untranslated page; with all of them complete there is no fallback to check),
while the same page at the root and a page translated to en that is not stale
do not. The rules for translators are in
Localização.
The API page is translated by catalog (ADR 0220)
The source of the Redoc page descriptions is still the api decorator text,
almost all in English. Each locale has a catalog
website/i18n/<locale>/openapi-textos.json, keyed by the sha1 (12 hex) of the
source text, with fonte and traducao. docusaurus.config.ts reads
DOCUSAURUS_CURRENT_LOCALE, applies the catalog with
scripts/docs/openapi-por-locale.mjs (a pure function, proven by mutation in
the spec next to it) and writes the locale spec to website/.openapi/,
outside git. Untranslated text stays as the source; en only translates the
texts that are in pt-BR. The 18 labels Redoc accepts in labels are
translated; its other fixed texts stay in English. docs:check fails an
orphan key (the text left the api) and an invalid entry (source that does not
match the hash, or a different number of `…` spans); missing only
warns, and parity counts it in its own section. pnpm docs:openapi-textos <locale> adds the missing keys to the catalog with traducao: "".
links-do-locale.mjs — a rewritten link skips the broken-link check
The markdown.hooks.onBrokenMarkdownLinks hook in
website/docusaurus.config.ts rewrites links that break only because
of a known translation gap (reference/, adr/, explanation/) to
pathname://. That's Docusaurus's official escape hatch, and escaping
is the point: a pathname:// link is never checked again, so a
wrong rewrite can't fail the build.
One did, for the whole pt-BR site (AT-221). The rewrite returned
pathname:///pt-BR/<slug>, but pathname:// isn't a domain-absolute
path — <Link> passes it through useBaseUrl, and the pt-BR build's
baseUrl already is /brabo/prd/pt-BR/. Every rewritten link came out
as /brabo/prd/pt-BR/pt-BR/...: 441 hrefs on 56 pages, all 404, with
docs:build green.
The rewrite now lives in scripts/docs/links-do-locale.mjs as a pure
function with a spec, and returns the bare slug — the locale comes from
the baseUrl of whichever locale is compiling, and the target page
exists there (translated, or served by fallback at the same slug). The
same module, run after docs:build in docs-check.yml, scans the
built HTML and fails on any href with a doubled locale prefix. A new
locale goes into LOCALES_COM_PREFIXO too, or its duplication passes
silently. Since ADR 0216 that list is en, ru and zh-Hans — pt-BR is
the default and has no prefix.
With partial translations, compiling en breaks a link from an
untranslated page (served from the pt-BR source) to a translated one in
any folder, not only in the three zones. So a link is also treated as a
gap when its target exists in the source docs/
(paginaExisteNaFonte) — that is the page the locale serves, translated
or by fallback. A target that exists nowhere still fails the build.
The adr/ exemption used to swallow a misspelled ADR filename too:
it looked only at the shape of the name (NNNN-*.md), so
0055-politica-de-terminal.md — an ADR that never existed under that
name — was rewritten to a slug no locale has. Twenty-three such links
sat in the docs, 404 (AT-227). Since then a target that looks
like an ADR must exist in docs/adr/ before any gap exemption
applies, including a source inside adr/, reference/ or
explanation/; a wrong slug fails docs:build in every locale.
What the guard still does not catch: a non-ADR link from inside one of the gap zones whose target doesn't exist in the repository at all.
Publishing, one site per rung
Each permanent branch publishes to its own spot on the same GitHub Pages:
| branch | URL | indexed by search engines |
|---|---|---|
| — (index) | https://daneiel.github.io/brabo/ | ❌ |
main | https://daneiel.github.io/brabo/prd/ | ✅ |
qa | https://daneiel.github.io/brabo/qa/ | ❌ |
dev | https://daneiel.github.io/brabo/dev/ | ❌ |
All three have been symmetric since
ADR 0071; the root
is a generated page listing all three with each one's stamped version,
and every site has a selector at the top for switching rungs. Before
that, main published at the root, and that special case forced the
workflow to preserve /dev/ and /qa/ on a path that only ran a
third of the time.
The path isn't the branch name.
ADR 0073
separated the two: the address names the environment for readers,
and main is vocabulary for whoever commits. That's why main
publishes at /prd/; qa and dev match by coincidence, not by
rule. The branch→path map exists at one point per process — the
workflow's "which rung, and where it publishes" step, the DEGRAUS
table in docusaurus.config.ts, and the one in landing.mjs — and
everything else derives from it.
Two consequences worth remembering before touching this:
/brabo/main/used to be published, and no longer is. Since the tree is assembled and pushed withkeep_files: false, the directory disappears. What preserves saved links is the root's404.html, which rewrites the prefix (/brabo/main/<something>→/brabo/prd/<something>) as its own case, separate from the generic redirect — which would produce/brabo/prd/main/<something>. The loop guard only covers paths that exist (prd|qa|dev)./prd/was seeded fromgh-pages:mainduring the transition, for the same reason ADR 0071 seeded/main/from the old root: without it,/brabo/prd/would respond 404 betweendev's first push and the next promotion tomain. The seed is rewritten from/brabo/main/to/brabo/prd/, because404.htmldoesn't save sub-resources — CSS and JS requested at the old address would get HTML back with a 404 status, and the site would serve bare text.
This closes a gap in the pipeline: between a merge into dev and the
final promotion, reading the documentation for that state required
cloning the repository. docs-check builds the site on every PR but
discards the build — its verdict is "builds with no broken links,"
never "lives somewhere I can open." And it was exactly that gap
between writing and looking that let the API reference go live broken
for two releases.
Three details that aren't obvious and have each already cost a bug:
baseUrlcomes fromDOCS_BASE_URL, defaulting to the production value. It goes into every asset URL: a site at/brabo/dev/withbaseUrl: '/brabo/'loads the HTML and nothing else, and the page is broken with no error.- The rung is declared in
DOCS_BRANCH, not deduced frombaseUrl. This used to beBASE_URL === '/brabo/', and it worked whilemainwas the only one at the root. With all three under a subdirectory, that comparison turns false formaintoo, and the effect would benoIndexon the REAL documentation — outside Google, silently, with CI green, because nothing in the build fails for under-indexing. ADR 0073 is exactly the scenario this separation was built for: the path changed to/prd/andE_PRODUCAOdidn't need to know about it. noIndexoutsidemainrequiresforceIgnoreNoIndexin search.@easyops-cn/docusaurus-search-localdiscards every page with<meta name="robots" content="noindex">, so the two safeguards would cancel each other: the rungs would publish with search dead, a 666-byte index, "No results" for everything.noIndextalks to external search engines;forceIgnoreNoIndextalks to the local index.mainassembles the tree before publishing, bringing/dev/and/qa/back.keep_files: truewould be simpler and would be wrong: it would never remove anything, and a page deleted from the repository would stay published forever.
Publishing is TWO workflows, and only one is ours
This trips up anyone looking for the deploy in the Actions tab:
| order | workflow | where it lives | what it does |
|---|---|---|---|
| 1 | Documentação (docs-deploy.yml) | .github/workflows/ | builds the site and commits to gh-pages |
| 2 | pages build and deployment | dynamic/pages/ — generated by GitHub | reads gh-pages and serves it |
The second one isn't in the repository and doesn't show up in the list of versioned workflows; GitHub creates it on its own when the Pages source is a branch. Our workflow ends at the commit — it does not publish to Pages, even though its job is still called "Publish to GitHub Pages."
Practical consequence: the site can be stale even with
Documentaçãogreen. If Pages'build_typeisn'tlegacy/gh-pages, the commit happens and nobody serves it — and nothing in CI turns red. The check isgh api repos/daneiel/brabo/pages, recorded in Rulesets.
The whole design, with the discarded alternatives, is in ADR 0034.
The site's colors come from the product
website/src/css/custom.css IMPORTS design/tokens.css — the same color
source as apps/web (ADR 0181) — and only points Infima's --ifm-* at the
tokens: dark is black, light is white, primary terracotta (--accent), code
highlighting through the --syntax-*. No hex is copied. The fonts stay the
system ones on purpose: the site does not load the families the --font-*
name. scripts/docs/contraste-do-site.spec.ts resolves the text --ifm-*
down to hex in both themes and fails below 4.5:1 against the background (and
fails as BLIND if nothing resolves).
The exception is the root index (/brabo/, written by scripts/docs/landing.mjs):
it is standalone HTML on Pages, cannot reach tokens.css and therefore COPIES
the values, in both themes through the system preference. The copy is tied by
scripts/docs/landing.spec.ts, which resolves the tokens and fails the value
that diverges — it already turned petrol once after ADR 0181.
Running it on your machine
website/ has its own pnpm-lock.yaml since ADR 0117 —
the root pnpm install no longer reaches it, so the first time (or after its
lockfile changes) needs one extra step:
cd website && pnpm install && cd .. # only the first time, or when website/pnpm-lock.yaml changes
pnpm docs:check # validates the map + checks generated content is up to date
pnpm docs:generate # regenerates
pnpm docs:drift # simulates the PR check (origin/dev...HEAD)
pnpm docs:build # the build CI runs — pnpm --dir website build under the hood
pnpm docs:start # local server, with hot reload
# does the API reference render? needs the build above, and isn't part
# of docs:check because that one doesn't build the site
node scripts/docs/api-render-check.mjs
node scripts/docs/links-do-locale.mjs # no href with a doubled locale prefix (AT-221)
Or, if you're in Claude Code, /sync-docs runs the whole cycle and
delivers a report of what changed, what became a TODO(human), and
what was deliberately left unchanged.
Conjunction by default, disjunction when the doc was split
A rule's docs: list is a conjunction: every document listed has to
change, or the rule fires. That's the right default — it's what makes
"changed the gate, update the gate doc AND the rules" enforceable.
It became wrong exactly once, and the fix is docs_alternativos:, a
disjunction: any one of the listed documents satisfies the rule. It
exists because business-rules.md was split by size (Cost and
Authentication alone were half of a 640 KB page), so a business rule now
lives in one of three files. Under conjunction, changing an auth rule
would demand the index too — a file that no longer contains that rule —
and the way out for whoever got asked would be the docs-not-needed
escape hatch. A rule that teaches people to use the escape hatch is
worse than no rule: it trains them to ignore the check.
docmap.mjs validates both lists the same way: a path that doesn't
exist is the same silent error as a dead glob — an alternative with a
typo could never satisfy the rule, and nobody would see it.
When the check complains unfairly
It will complain unfairly sometimes. A refactor that renames internal
variables triggers dominio-e-regras without changing any business
rule. That's expected: the map works by file path, not by semantics.
There are two ways out, and both require someone explaining why:
PR label: docs-not-needed
or in the body: docs-not-needed: internal refactor, no RN changed
Use it without guilt when it applies. The escape hatch exists on purpose: without a legitimate way out, the habit that forms is to cheat — a cosmetic commit to the doc just to make the check pass. Then the mechanism starts lying, which is worse than not existing.
One class of PR gets the body line from a bot, and only one: a Dependabot
PR whose diff is nothing but action pin changes (the uses: SHA and its
version comment). A step right before the drift writes the line, marked as the
bot's, and the drift still reads only the line — see
A pin bump justifies itself.
Because editing the body with GITHUB_TOKEN fires no edited event, and a
re-run reuses the original payload, the drift reads the body from
PR_BODY_FILE when that step rewrote it, and from the event otherwise.
What's not okay is using the escape hatch out of haste. If you
used it three times in the same week for the same rule, the rule is
the problem: adjust the glob in docs/.docmap.yml, or lower the
severity from block to warn.
When the mechanism itself is wrong
| symptom | fix |
|---|---|
| rule fires on an irrelevant change | glob too broad — narrow the watch |
| rule never fires | dead glob — pnpm docs:check flags it |
| generated content is always stale in CI | someone hand-edited it; fix the generator or the source |
build breaks on { or loose HTML | .md is CommonMark; if it needs a React component, rename to .mdx |
| the audit reports itself | add the file to the META list in audit.mjs |
What this mechanism doesn't do
It doesn't check whether the text is correct. It checks whether
the text was reviewed when the code changed, and whether the lists
are complete. A factually wrong sentence nobody touched passes every
check — that's what human reading, and /sync-docs, are for.
It doesn't write documentation. It generates an inventory and demands review. What a variable does when it's wrong, why a cap exists, what to investigate during an incident — that's still work that has to be written.
It doesn't enforce README.md, nor anything no rule watches. The
docmap is a floor, not a ceiling: as of 2026-07-29 no rule was watching
the heart of observability (tracing.ts,
infrastructure/observability/**, telemetry/**, lib/logger.ts),
and README.md is barely required by any rule at all. Delivering only
what CI demands is how the two sentences about
OTEL_EXPORTER_OTLP_ENDPOINT stayed wrong for months. When you change
code, sweep docs/ looking for what the change made false — including
what nobody asked you to look at.