Skip to main content
Translation from 2026-10-16; the original has changed since.

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:

levelhow it workswhen to use it
Generatethe doc comes out of the code; drift is impossiblethe list IS the content
Verifythe doc is hand-written, CI checks that it's completethe prose matters more than the list
RememberCI warns whoever opened the PRwhen 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 prosesource of truth
ADR count, next ADR numberthe files in docs/adr/
RN countthe ### RN-NNN headings in business-rules.md
LLM provider countthe 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 total592
match the pattern187
correct (within ±3)116
wrong71
unresolved path0

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's Código and RN-626's SessionPage.tsx line). 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 infra message; since RN-617 the Infra Lead converses, and the test now uses qa as 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 make target the Makefile does 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 tag or manual — or a workflow it cites is missing or does not have that trigger in its parsed on: (schedule:, pull_request, push: tags). A manual segment 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:line references that no longer resolve
  • ADRs in proposed status 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:

stageresult
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: "".

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:

branchURLindexed by search engines
— (index)https://daneiel.github.io/brabo/❌
mainhttps://daneiel.github.io/brabo/prd/✅
qahttps://daneiel.github.io/brabo/qa/❌
devhttps://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 with keep_files: false, the directory disappears. What preserves saved links is the root's 404.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 from gh-pages:main during the transition, for the same reason ADR 0071 seeded /main/ from the old root: without it, /brabo/prd/ would respond 404 between dev's first push and the next promotion to main. The seed is rewritten from /brabo/main/ to /brabo/prd/, because 404.html doesn'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:

  • baseUrl comes from DOCS_BASE_URL, defaulting to the production value. It goes into every asset URL: a site at /brabo/dev/ with baseUrl: '/brabo/' loads the HTML and nothing else, and the page is broken with no error.
  • The rung is declared in DOCS_BRANCH, not deduced from baseUrl. This used to be BASE_URL === '/brabo/', and it worked while main was the only one at the root. With all three under a subdirectory, that comparison turns false for main too, and the effect would be noIndex on 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/ and E_PRODUCAO didn't need to know about it.
  • noIndex outside main requires forceIgnoreNoIndex in search. @easyops-cn/docusaurus-search-local discards 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. noIndex talks to external search engines; forceIgnoreNoIndex talks to the local index.
  • main assembles the tree before publishing, bringing /dev/ and /qa/ back. keep_files: true would 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:

orderworkflowwhere it liveswhat it does
1Documentação (docs-deploy.yml).github/workflows/builds the site and commits to gh-pages
2pages build and deploymentdynamic/pages/ — generated by GitHubreads 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ção green. If Pages' build_type isn't legacy/gh-pages, the commit happens and nobody serves it — and nothing in CI turns red. The check is gh 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​

symptomfix
rule fires on an irrelevant changeglob too broad — narrow the watch
rule never firesdead glob — pnpm docs:check flags it
generated content is always stale in CIsomeone 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 itselfadd 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.