**Three languages, engineered so the bill stays flat — and not one page published, because that
line is the editor's.** The brief was one sentence: *make the site multilingual at
`pt.newsroom.sgit.ai/{language-locale}` for English, French and German, and really focus on doing
it effectively from an engineering point of view, so that as we make new changes the translation
costs and prompts don't grow exponentially.* This release is the answer to the second half. The
machinery is built, proved to 100% coverage in a rehearsal, and gated. It serves nothing, and the
reason is a sentence in `CLAUDE.md` that no automated session here may edit.

**The number the design turns on, measured on this repository rather than estimated.** The rendered
site is 186 101 words. The authored prose behind it is **18 948**. The difference is not padding: it
is 106 920 unique words of names, SHA-256 hashes, dates, byte counts, source identifiers and frozen
excerpts, every one of which *must not be translated*, because a translated excerpt is different
evidence and a translated name is a different claim. Translating the render means paying to
translate the exact material rules 1, 3 and 6 forbid touching. So the first decision is not a saving
that happens to be correct, it is a correctness rule that happens to be ten times cheaper:
**translate the source, never the render.** The second column matters more — a pipeline that
translates pages pays again on every build; one that translates sentences pays once per sentence and
nothing on a rebuild. This site builds on a schedule.

**The key of a segment is the SHA-256 of its own source text, so there is no such thing as a stale
translation.** Change one word of a paragraph and it becomes a different segment whose translation
does not exist yet. No "needs review" flag, no timestamps to compare, nothing anybody has to
remember to invalidate: **staleness is a cache miss**, and `build/i18n.py status` counts cache
misses. `words to do` is the entire cost of the next run — after the first pass, the cost of the
week's edits and nothing else. The previous translation of a changed paragraph is kept as an orphan:
not served, not deleted, there for reuse, costing nothing. And because identical text hashes once,
the chrome that appears on all 223 reader pages is translated once; the average segment on this site
is reused eleven times.

**Deny, do not allow — and the reason is which way the mistake shows up.** The data layer is
translated by default and a short list says what never is. A list of the fields that *are* prose is
the version that rots: a field added next month falls silently out of every locale and nobody finds
out, because an untranslated page still looks like a page. Deny-by-default fails the other way — a
field that should not have been translated turns up as a wasted segment in the next `todo`, where a
human reads it, and one line fixes it for ever. **Prefer the failure you can see.** The same
reasoning removed `PROSE_KEYS` entirely, and replaced the growing list of evidence field names with
one structural rule: **evidence is an object, not a field.** Any JSON object carrying a `sha256` is a
record of somebody else's bytes and nothing inside it is translated, at any depth, whatever its
fields are called — which protects a field added to a source record on the day it appears.

**The render is the discovery surface, not the thing translated.** Reading the authored files finds
the prose in `artigos/` and `dados/`. It does not find the sentence a builder assembles with an
f-string, or the clause sitting between two inline entity links, and on this site those account for
more of what a reader actually reads than everything in `dados/` put together. The alternative was
wrapping four hundred Portuguese strings across twenty builders in a `t()` call — and this
repository has already paid for that kind of bulk rewrite once, in the v0.22.0 defect that produced
`save(estado)` and `this.items = itens` and took two rewrites from scratch to undo. So
`build/i18n.py` reads the finished HTML and takes the runs. The unit is still authored text, the key
is still its hash, it is still translated once however many pages carry it; what the render adds is
completeness, so coverage can reach 100% and a gap is a number instead of a surprise.

**Generated text is formatted, never translated — this is the exponential arriving through the back
door.** `Lisboa · segunda-feira, 14 de setembro de 2026` is a different string tomorrow, so its hash
is different tomorrow, so a translation of it is a cache miss for ever and the site pays for the same
sentence every day it builds. A naive segment store walks into this within a week. `i18n.gerado()`
keeps date lines, version badges and counts of things this site holds out of the memory; the
coverage report counts them as `generated` rather than `missing`, because calling them missing would
invite exactly the wrong fix.

**What is never translated is decided from the data, not from a typed list.** `i18n.nao_traduzir()`
takes the graph's verbs and the sentences they read as out of `dados/ontologia.json` — rule 6 says
the graph reads aloud in Portuguese, and an English edge label is a different graph — the names out
of `dados/entidades.json` and its siblings, and the excerpts out of every record with a `sha256`.
The graph test is a substring test against the ontology's own reading fragments, so it can in
principle fire on prose that happens to contain « organiza ». That mistake costs one sentence left in
Portuguese on a translated page, which the report counts and a reader can see; the opposite mistake
is a translated edge, which nothing would catch. **Fail in the direction you can see.**

**The back office is not localised, and that was half the words.** `/newsroom/`, `/admin/`,
`/redacao/`, `/entregas/`, `/briefs/` and `/docs/` have one reader — the editor of record — and that
reader reads Portuguese and English. Translating the review board into German is paying to translate
a to-do list nobody will open. 223 pages can have a locale twin; the other 25 stay as they are.

**`build/locales.py` works on finished pages, and its safety property is the whole design.** A text
run is replaced **only** when its normalised form hashes to a segment the memory already holds;
everything else passes through untouched. So the default for a name, a hash, a date, a byte count
and above all a frozen excerpt is: unchanged, because none of those was ever extracted. The failure
mode is a Portuguese sentence on an English page — visible, countable, reported. It is not a
mistranslated excerpt, which a reader who cannot read Portuguese would never notice. The renderer
splits each page once and gives each region to exactly one pass, which makes double counting
impossible rather than unlikely: an earlier version ran a block pass and then a text pass over its
own output, met a translated paragraph freshly split at its inline entity links, and reported it as
untranslated. Entity links come back by themselves after substitution, because entity names are
verbatim in every language. Relative links get one more `../` only when they leave the localised set,
and which case it is, is decided by asking whether the target is a page this run localised — so the
rule maintains itself as pages come and go.

**Gate 44, and it was tested by sabotage rather than by assertion.** `build/gates_i18n.py` checks
that the language register is coherent and `pt` is the only language of record; that **the key of
every segment really is the hash of its own text**, because if somebody hand-edits `fontes.json` the
identity the whole memory rests on breaks in silence; that no orphan is served; that every published
locale mirrors the Portuguese page set exactly; that `<html lang>` is right and every page names the
Portuguese original as `x-default`; that inline `<script type="application/json">` still parses after
substitution — this repository has produced valid HTML around broken JSON before, and only the
browser saw it; and, the one that matters most, that **every excerpt, name and graph reading in the
Portuguese page is byte-identical in the translated page**. A rehearsal was sabotaged two ways — a
translated excerpt and a deleted page — and both were caught and named.

**The proof, before anything was claimed.** `python3 build/locales.py --ensaio en-gb <dir>` with the
memory filled renders all 223 pages at **100.0% coverage**, and `python3 build/gates_i18n.py en-gb
<dir>` passes against that tree. Two defects were found that way and not by reasoning: the language
of record was being rendered as a locale, writing a complete second copy of the Portuguese site into
`/pt/` with every relative link one level short — the only thing that noticed was the page count in
its own output line — and the chrome scraper, when it used a regex over source text instead of
`ast`, was splitting implicitly concatenated literals into fragments no translator could act on.

**Nothing is published, and this is the honest part.** `CLAUDE.md` says *"Everything a reader sees is
European Portuguese under AO90"*. That is the constitution of this repository, `Edit(CLAUDE.md)` is
in the deny list of every automated session, and it means no build may serve an English page until
the editor of record amends it. A session that can rewrite the constitution by which it is judged is
not being judged by anything, and the answer is not Write, or Bash, or a file alongside that says
otherwise — those are the same edit under another name. So all three locales sit at `preparado` in
`dados/i18n/locales.json`, `build/locales.py` renders nothing and says so, and the amendment is
written out word for word for the editor in `redacao/issues/010`. It does not loosen the rule; it
changes its axis, from *everything a reader sees is Portuguese* to *everything a reader sees walks
back to a Portuguese page that was checked against bytes* — the same demand for provenance, said in
a way a translation can satisfy and an invention in English cannot.

**Also in this release: `build/before_push.py` was advising a major release for every push.** It
told a session to take `v0.24.0` when `v0.23.6` was taken, which is the second component. The minor
is the **third**, CLAUDE.md says every push to `dev` is a minor release, and the tag job computes
`NEXT_MINOR` from the third component and rejects anything that is neither that nor `NEXT_MAJOR`. The
advice sent sessions to write a release note under a number CI would refuse — a failure found after
the note is written and the tree is green. It now says `v0.23.7`, names the major separately, and
says which is which.

**What a future session should not have to rediscover** is in `docs/guidance/multilingual.md`, with
the measured numbers, the four-column coverage report and why conflating any two of its columns
produces a dishonest figure: `build/chrome.py` runs before `build/locales.py`, so `sitemap.xml` and
`llms.txt` do not yet know about locale pages and rule 7 says the sitemap agrees with the tree —
that is on the list before any language goes live. Do not translate the back office to raise the
coverage number. And do not add a field to `PROSE_KEYS`: there is no `PROSE_KEYS` any more, and the
reason it was deleted is the third decision.
