pt.newsroom.sgit.ai · administration
Back to the paper · back office · guidance
v0.23.15

This page is generated, and in English. Each release note is a markdown file in admin/versions/; admin/versions.json is the index; this page is rendered from the two by build/versoes.py. Text does not live inside HTML here — a release note is a document, and a document that can only be read as markup is a document with extra steps. It is in English because its audience operates the newsroom rather than reading the paper; the rule and its exceptions are in the guidance.

Releases

Every push to the release branch is a minor version, and carries a note here saying what changed and why.

The number lives in admin/build/version.txt and is bumped exactly once per release. The commit subject repeats it, the badge appears on every page, and CI checks the three agree before tagging and deploying. A red validate is not a release.

VersionDate What changed, and why
v0.23.152026-10-02

The four department agents have English names. pesquisa.pt is research.pt (@Research), redacao.pt is desk.pt (@Desk), verificacao.pt is verification.pt (@Verification) and bastidores.pt is backstage.pt (@Backstage). The id is a protocol address — it names the mail folder and will be the address other sites' agents write to — and the alias is an operator handle. Both are operations, and operations are English. The editor called bastidores.pt a mistake on the day it was about to be baked into a signing key and published in a contact file other agents would cache; that is the one moment a rename is cheap, so all four went together.

Four things did not move, and the register says why (dados/agentes.json, o_que_nao_mudou_na_renomeacao). The display names — Pesquisa, Redação, Verificação, Bastidores — are what a reader sees on the newsroom-floor page, in Portuguese: content. The id_curto department keys are what gate 12 checks, and gate 12 is in deny-listed build/gates.py: they move with the English migration (task #5), not before. The @redacao.local mail domain is in every message already sent. And the bridge id editor-para-bastidores is the key under which the editor's own browser stores the append credential; renaming it would silently lose that credential.

Nothing that is a record was rewritten. Twelve messages carry From: bastidores.pt in their headers, and nine run records are signed bastidores.pt. Mail is never edited and a run record says who ran. Each old id is now in identidades_historicas with renomeada_para: the mail reader resolves an old address — and its alias — to the live agent on the way in, so one agent does not appear under two names; gate 35 accepts a retired id on an old run record. It still fails an agent nobody registered, and still fails the bootstrap session's id, which was retired without a successor — both broken on purpose and both red. The mail folders moved with git mv, so the box a message sits in was right before a line of code changed.

Not done, and said: the mail README and this note both still spell the old ids, deliberately, as history. The three translation files hold orphaned translations of one line that named pesquisa.pt; the i18n gate reports them kept and none served, which is what a changed source is supposed to produce.

admin/versions/v0.23.15.md — this note as markdown, which is what it is.

v0.23.142026-09-27

adamastor.blog is frozen, hashed and registered — the first source about the Portuguese startup ecosystem itself rather than one event's website. Before this, 83% of the register was startupsummit.io, and only 4 of the 67 organisations in the graph appear anywhere in Adamastor's digest. Now: 104 frozen files — robots.txt, the publisher's own llms.txt, the home and about pages, the sitemap, the digest's RSS archive, the events feed and one feed per category, and 92 posts, January 2025 to last week, by 11 named authors. From them, dados/adamastor.json: every post's title, date, byline, outbound links and lexicon tags; 45 upcoming events with when, where and category; and 406 domains the digest pointed at. It is served at /api/v1/adamastor.json, with one file per post.

Rule 3 held by construction. Not a sentence of the digest's prose is in a data file: the longest value outside the notes is a source id. Event descriptions are the organisers' writing and are not read at all. The 406 domains are not 406 companies — they include YouTube, Spotify, Google Forms and a newsletter's click tracker — and turning any of them into a company record is task #7, which needs a source of its own.

## Five things went wrong on the way, and each is fixed

1. Re-running the register would have wiped the event off the site — before Adamastor was touched. build/extract.py read the event from the newest dated folder. Two research deliveries had frozen their sources into folders of their own (15 and 23 September), so the newest folder held no speakers page: re-running it wrote 0 people, 0 organisations and 0 sessions, and the core gate went red. It had stayed hidden only because the build runs that step with --fetch alone. Now the event is read from the newest folder that contains the event; every folder is still registered file by file — which also registers, for the first time, 24 delivery sources that had been frozen and never entered the register.

2. The graph pointed 128 edges at nodes that did not exist. Every frozen file gets a captured-in edge to its folder's date, but capture nodes were made only for the event's captures.

3. The publisher's sitemap is incomplete. It lists 78 posts; the RSS feed lists the newest 50; 14 September-2026 posts appear in no sitemap at all. The first capture trusted the sitemap and froze an archive with its newest fortnight missing — noticed only because it reported 0 tracking redirects when a post read earlier plainly had some. They were all in the missing posts. The capture now reads both lists; the gap is recorded as an observation about the publisher's file, not papered over.

4. The robots.txt check could not say no. Python's urllib.robotparser applies the first matching rule; Adamastor's file opens with Allow: /, so under it every Disallow was shadowed — tested against the frozen file, it called /api/, /dashboard/ and /login all fetchable. The capture's docstring said every URL was checked. It was, by a check that always answered yes. Replaced with RFC 9309 matching — longest rule wins, Allow wins a tie — and tested against the real file on eight paths. Every page actually fetched is allowed under the correct reading: nothing disallowed was taken, but only because nothing disallowed was on the list, which is luck and not a control.

5. Nothing checked a data file for contact details. Rule 4 was enforced where data is read (extract.sem_contactos), and that works — an email planted in a post link was dropped. But an email planted in dados/adamastor.json itself passed both gate files green. Gate 46 now scans every JSON file the site builds from or serves, with two exemptions read from the repository rather than typed: the data controller's contact as the notice declares it, and the internal mail protocol's .local addresses. It catches an email and a Portuguese phone number on injection.

Not in the daily run, on purpose: a capture is about 14 MB, mostly each post page's framework payload, and re-freezing 92 unchanged posts every day is not worth what it costs the repository. Joining the scheduled run is the editor's decision. And for the question that started this: the digest mentions Web Summit 45 times — including Startup Portugal's Road 2 Web Summit programme for up to 125 Portuguese startups — and Estonia not once.

admin/versions/v0.23.14.md — this note as markdown, which is what it is.

v0.23.132026-09-24

For ten days every page on this site promised an event that had already finished. The masthead read «Lisboa · segunda-feira, 14 de setembro de 2026» as though that were today, and beside it «Faltam 3 dias para o Startup Summit Lisbon 2026». The Summit ran on 17–18 September. This was found on the 24th, six days after it ended, on 231 pages, with all five gates green.

Two claims, both false, and they fail for different reasons.

The dateline was mislabelled. hoje is not today: it is registo.json.atualizado, the date of the most recent frozen source. That is deliberate and right — every page here has to be deterministic, and a rebuild that changed 231 files because a clock moved would make every diff unreadable. The date was real. It was simply the capture's and not the reader's, and it did not say so. It now reads «última captura: 14 de setembro de 2026», which is a true sentence on any day anyone reads it.

The countdown was unknowable, which is the more interesting failure. How long until an event depends on when you are reading, and a file on a disk cannot know that. It had been computed as 17 September minus the capture date, so the moment the register stopped moving the countdown froze with it and went on being confidently wrong. No amount of care in the renderer could have made that number true, because the input the answer depends on was never available at build time.

So the server states what it can prove, and the browser adds the tense. The page renders «Startup Summit Lisbon 2026 · 17–18 de setembro de 2026» — a fact with no tense in it — and assets/quando.js appends the relative phrase from the reader's own clock: foi há 7 dias, é hoje, faltam 2 dias. Tested against three different reader clocks. Without script the reader gets a true sentence instead of a false one, which is the right way round for a publication whose whole argument is that it does not assert what it cannot show. The dates stay beside the phrase rather than being replaced by it: the dates are evidence, the phrase is arithmetic.

Gate 45 fails the build on any reader page that states a countdown or presents the capture date as the reader's. Broken on purpose first: putting the old line back turns 17 pages red.

This is the one place on the site where something depends on the moment of reading, and it had been handled as though it did not. Worth saying plainly before the site opens to a wider audience: the gates checked that every claim walked back to bytes, and this claim did — it walked back to a real date in a real register. It was still false.

admin/versions/v0.23.13.md — this note as markdown, which is what it is.

v0.23.122026-09-23

A fourth research delivery, and the first that validated against the schema without a single error. chatgpt-2026-09-18-politicas-delta — one source, one item, two claims, both found word for word in the bytes this newsroom froze. The editor of record approved the item; the article is commissioned and not written; nothing from the delivery is on a reader page.

One source, readable at the first attempt. src-pol-13, an official digitalGOV page dated 17 September 2026, HTTP 200, 306 214 bytes and 10 345 visible characters, with its SHA-256 in the register. No 403, no script-rendered wall, no 404. That is the first time in this section: the four policy stories already published are all built on the opposite experience, and three of them are partly about it.

Both claims confirmed, and the excerpt gate was checked against by hand rather than trusted. "O Cloud and AI Development Act é uma proposta da Comissão Europeia" and "Prevê igualmente um quadro comum de contratação pública" are both in the frozen bytes verbatim. A first hand-check reported the opening excerpt as absent; that was this session's own bug —   was unescaped after whitespace was collapsed, so the entities survived as literal text and the match failed. Done in the right order, both confirm. The gate was right, and the note is here because a verifier that cannot be wrong is not verifying anything.

Nowhere did the delivery's own account and the check disagree — which had not happened before. The 14 September delivery claimed in its notes that it had "confirmado o instrumento jurídico da ANIA" while the check said the page returns no text. This one says the European Commission's primary page returned HTTP 429 and that it therefore did not use it as a source. That is the right call and it is recorded rather than worked around: if that page is ever frozen it is a new source and a new excerpt, not a retroactive confirmation of this one.

Zero schema errors and no ontology change. The one proposed entity is politica:cloud-and-ai-development-act, type Política — already in the ontology, with an identifier matching the published pattern. No vocabulary proposal, so no version to take for it. The six schema errors still printed by the delivery step belong to the 14 September delivery and are unchanged.

Three things ride in the record without being claims, and that is worth naming. The acronym CADA, the procedure 2026/0138(COD) and the 3 June 2026 presentation date travel as attributes and dates, not as claims — so the excerpt gate never looked at them. All three were checked by hand against the same frozen copy and all three ARE in the bytes. They are sound, but they satisfy rule 2 by accident rather than by construction, and redacao/issues/014 says that if any of them reaches the prose it becomes a claim with its own excerpt first.

One inference is named and excluded. The delivery's why_it_matters says the proposed framework articulates "com a execução do Plano Nacional de Nuvem Soberana". "Nuvem Soberana" appears on that page only as a tag; the articulation is ours, not the source's. It is the kind of sentence that passes unnoticed because it is plausible, so it is written down in the issue and in the commission to Reporting as something that does not reach the page.

The thing this article has to get right is that CADA is a proposal and not an act. Every policy story on this site so far is an announcement whose instrument could not be read. This one is a text that declares itself a proposal, in the ordinary legislative procedure, with Parliament and Council still to come. Promoting it to an instrument in force would be exactly the error the section exists not to make.

The valuable negative. A second independent search again failed to find the readable Série I PDF of RCM 2/2026 and any published act designating Portugal's market surveillance or notifying authority under the AI Regulation. Two assistants, two dates, same wall. That is evidence for redacao/issues/006 and redacao/issues/002 and it is not a story.

Gate 41 caught this release's own mistake. The commission to Reporting quoted a literal source mark, and the back office may not carry one — it reports on the newsroom, not on the world. The message names the identifier and leaves the mark to the issue. A rule with a gate is the only kind that holds.

What is deliberately NOT done here. redacao/revisoes/chatgpt-2026-09-18-politicas-delta.json does not exist. The approval was given by the editor in conversation; the file is the editor's line, it says so in its own text — "é escrito por uma pessoa … Uma execução automática nunca escreve aqui" — and Edit(redacao/revisoes/*) is deny-listed. Writing it through another tool would be the same act renamed. So build/entregas.py keeps the delivery at por_rever and gate 13 keeps item-pol-12 quarantined, which is the correct and safe state. The JSON is written out ready to paste, and the delivery, the frozen source, the verification record, the issue and the commission are all on the register and dated in the meantime.

921 pages, four languages, 44 gates green, browser gate unchanged.

admin/versions/v0.23.12.md — this note as markdown, which is what it is.

v0.23.112026-09-23

The amendment to the language rule is written out word for word, and the emergency exit the constitution prescribes turns out to write into a folder nothing reads. Neither of those is a change to a page a reader sees. Both are the editor's line, and this release is the record of them being put in front of them by the route that actually arrives.

The editor asked for CLAUDE.md to be amended, and asked to be prompted to authorise it. The prompt cannot exist, for two reasons, and the second one is the interesting one. The first is this repository's own: .claude/settings.json lists Edit(CLAUDE.md) under deny, not ask. A deny rule refuses outright and never prompts — that is precisely the difference between the two lists, and it is why a constitution belongs in the first. deny also outranks both ask and allow, so adding an ask rule beside it changes nothing: the existing entry has to move. The edit was attempted rather than assumed, so the refusal is demonstrated: "File is in a directory that is denied by your permission settings."

The second guard is outside this repository, and no authorisation given here reaches it. Moving the entry was refused by the harness's own self-modification classifier, which can only be lifted by a settings permission rule — the very edit it is blocking. The editor authorised the move in conversation; that authorisation is not a rule of this repository and there is nothing here to grant it with. It was not routed around. Rewriting the JSON through Python or a shell redirect would have been the same edit under another name, which is the thing the guard exists to stop, and a session that edits its own guard rail on request is a session whose guard rail is a suggestion.

So the amendment is written out to be applied by hand, in two places and not one. redacao/issues/010 proposed only the ## Language section; the opening paragraph carries the same sentence, and leaving it would have made the constitution contradict itself on its own first page. Both replacements are in the message named below, exactly as they should read.

The amendment 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, phrased so a translation can satisfy it and an invention in English cannot. It still forbids an English page with nothing to walk back to, a translated excerpt or graph verb, and a locale served without the editor's publicado.

Then, going to write that message, a defect in the escape hatch itself. CLAUDE.md ends with the instruction that governs every session that gets stuck: "When blocked, write to the editor's inbox (redacao/correio/editor/entrada/) and stop. A run that stops honestly is a good run." A session that does exactly that writes somewhere no reader in this repository visits. Three things diverge at once:

* The format. build/newsroom_team.py is the single reader of that folder and it walks redacao/correio/**/*.eml. Messages are .eml files with From/To/Date/Subject headers. A .json in the same folder is invisible to it. * The mailbox. The mailboxes are the agents of dados/agentes.json — pesquisa.pt, redacao.pt, verificacao.pt, bastidores.pt, dinis.humano. There is no editor agent. redacao/correio/editor/ is not a mailbox in this protocol; it is a folder nobody declared and nothing reads. * The path. By rule 2 of redacao/correio/LEIA-ME.md, reaching another agent means leaving the file in expedicao/<recipient>/ and letting the recipient move it to their entrada/. Writing straight into another agent's entrada/ breaks single-writer, which is rule 1. The constitution's instruction names the one move the protocol forbids.

The consequence is not theoretical: a message has been sitting unread for eight days. redacao/correio/editor/entrada/2026-09-15-migracao-en-bloqueada.json — a session blocked on the English migration did exactly what CLAUDE.md says, and wrote a careful note naming three deny-listed files and offering the editor two ways out. It never rendered and never arrived. This session only found it by writing beside it and then checking whether its own message appeared. That is the same failure shape this site chases everywhere else: something that looks like it worked because nothing said it did not. No gate catches this, which is why it lasted — a file in a folder nobody reads fails nothing.

It was not repaired by hand. Re-dispatching another agent's message would cross the single-writer boundary that makes the protocol worth anything, so the 15 September file is named and left exactly where it is. redacao/issues/013 carries the two ways out, the recommended wording, and the gate that either one needs: fail the build if anything under redacao/correio/ is a file the single reader does not read.

This release's own message went by the correct route, which is the proof the route arrives. redacao/correio/bastidores.pt/saida/dinis.humano/006__…eml with its dispatch copy in redacao/correio/expedicao/dinis.humano/, where dados/correio.json now records it as em trânsito — delivered when the editor moves it, which is the recipient's move and nobody else's. It is on /newsroom/mail.html, which the 15 September message never was.

Nothing a reader sees changed. 920 pages, four languages, 44 gates green, validate green. Two decisions are open and both are the editor's because both are in CLAUDE.md: the language-rule amendment, and which mailbox a blocked session should use.

admin/versions/v0.23.11.md — this note as markdown, which is what it is.

v0.23.102026-09-23

French and German are live, and a guard that refused too much stopped leaving whole Portuguese sentences on every locale page. The editor asked for the other languages to be findable from the front page. fr and de are now publicado in dados/i18n/locales.json, which makes three languages beside the Portuguese page of record, 224 pages each, and a switcher on every page that points at THIS page in the other language.

German was translated the same way English and French were: from the 1 051-segment memory, never from the render. 998 segments at the start of this release, 457 of them still Portuguese in the German column, 13 953 words. They went in batches against the Portuguese source with the English translation beside it for disambiguation, which is what the content-addressed memory is for: a segment is keyed by the hash of its own source text, so a segment translated once stays translated however many of the 224 pages carry it, and a source that changes is a cache miss rather than a flag somebody has to remember to set.

The guillemet guard was refusing whole runs, and the report could not see it. Anything inside «…» on this site is somebody else's words, and a translated quotation is a misquotation — so build/locales.py refused any text run containing a guillemet outright. The comment said this cost «a handful of chrome lines their translation». It did not. It left 23 distinct runs Portuguese on every locale page, 174 occurrences, and the worst of them was the sentence on /empresas/ explaining what happens to a marker value such as «Independent» — a whole Portuguese sentence, repeated 69 times on one page, refused because the marker it names is quoted. None of it was counted, because a run refused at the door never becomes a miss. That is the same failure shape this repository keeps meeting: a green report over a record that had changed meaning.

A quotation is now a hole in a template, exactly as a number is. i18n.citar() takes each balanced «…» span out and replaces it with ¤; the prose around it is the segment and translates once; i18n.descitar() puts the span back byte-identical. Not one character inside the guillemets is ever touched, which is the whole of what the guard existed to protect — gate 44 still asserts every excerpt, name and edge of the graph is byte-identical in every language, and it is.

What is still refused is an UNBALANCED guillemet, and the distinction is exact. Where a quotation opens in one run and closes inside the markup that follows, the text after « really is quoted and translating it would misquote a fragment. The one safe shape is a stray that carries no quoted text with it: a « that is the run's last character, or a » that is its first. That is exactly what the split around «Independent» produces, which is why those two runs are now translated and the quotation between them is untouched. The one place a quotation is genuinely split across markup with quoted text on both sides — the two candidate sentences in the regulator story — stays Portuguese, and that is the guard working rather than failing.

Seventeen translations already in the memory were converted mechanically rather than retyped. Masking changes a segment's key, so every sentence carrying a quotation became a new segment and its old translation became an orphan. Applying the same mask to the old translation and checking that the hole count matches the source's converted 17 segments in three languages with zero mismatches. The other 15 were written by hand. This is the cost curve the design exists for: a rule change should not cost a full retranslation.

The verbatim harvest was missing the field names the DATA actually uses. i18n.verbatim() named cargo_listado and organizacao_listada — the ontology's names for those fields — while dados/pessoas.json calls them papel and organizacao. The consequence was invisible while the coverage metric could not see unaccented labels, and it is not small: a hundred-odd listed roles read verbatim out of a frozen page («CIO & Co-founder», «Managing Partner») were classified as Portuguese awaiting translation. Rule 3 says third-party text is linked and never rewritten and rule 4 forbids any characterisation of a named person, so translating a listed role would be inventing one. papel, organizacao, dia, tema, formato, quando and correspondeu are now named alongside the others — the day a programme declares, the event's own topic, the format it names, and the words the published lexicon matched are all read verbatim out of somebody else's bytes. titulo is deliberately NOT among them: it holds session titles, which are verbatim, AND this newsroom's own headings, which are not, and guessing would silence prose that should be translated.

An &amp; was hiding a name from its own verbatim test. The test ran on the raw run, so «Gacs Ltd &amp; Gacsym Ventures» matched no name in dados/ and an organisation stopped being recognised as an organisation. This is the second time the same entity has found the same class of bug; the test now runs on the unescaped form as well as the raw one.

--relatorio now prints the list, not just the number. dados/i18n/vocabulario.json documents itself as extended from that report — «--relatorio lists every run a reader would see in Portuguese that the memory cannot serve, most frequent first» — and the report printed a table and no list. A number nobody can act on is the same problem as a number that reads 100% over a Portuguese page. 39 short labels found that way are now declared in the catalogue and translated in all three languages: O registo, O aviso, · fonte:, · ficheiro:, Ficheiro congelado, Papel listado, the section names in their definite-article form, and the · pt.newsroom.sgit.ai page titles.

The measurable result: 15 536 substitutions per locale page set, up from 14 153. 1 383 runs a reader used to see in Portuguese on an English, French or German page, now in the language the page claims to be in. All 44 gates green, the browser gate green over 20 pages, and the utility strip still 1168×33 in both chromes.

An honest remainder, measured rather than guessed. i18n.parece_portugues() finds Portuguese by accent or function word, so it cannot see an unaccented label — O registo, Papel listado. Widening it to «any run with a letter that is not a name and not a machine fact» was tried during this release and reported 1 798 occurrences across 154 distinct runs, which is the true size of what the current metric cannot see. But it also reported, as Portuguese, the lexicon's published regular expressions, the ontology's ASCII type names, the graph's reading templates, the back office's declared English, and names truncated for display with a · 5× suffix. Each of those is a rule of its own to write, and writing seven rules in a hurry inside a release is how a metric starts lying in the other direction. The widening was reverted, the 39 labels it positively identified were declared and translated, and the measurement is written down in redacao/issues/012 so the next session starts from the number instead of rediscovering it. The coverage figure reads 100% over a denominator that is honest about accented Portuguese and blind to unaccented labels — and this paragraph is the disclosure, because a footnote nobody writes is how the last one survived three releases.

CLAUDE.md still says «Everything a reader sees is European Portuguese under AO90». Three languages now, on the editor's conversational authorisation, and the file is theirs to amend. The wording is in redacao/issues/010, unchanged and still waiting.

admin/versions/v0.23.10.md — this note as markdown, which is what it is.

v0.23.92026-09-22

English is live at /en-gb/, the back office speaks English in its addresses too, and the masthead says beta. Four things the editor asked for, and the third of them — "it's ok to publish this and put it live, since this is still a beta newspaper" — is what let the first one out of the gate it was sitting behind.

The beta tag, beside the wordmark, the way Google carried one for years. A superscript on the masthead rather than a banner across the page, because it is a standing fact about the publication and not an alert. It is a link — to /sobre/, the page that says what is real on this site today and what is not yet — because a beta tag that does not say what is unfinished is decoration. The word is the same in every language, which is why it is not a translatable segment: build/i18n.py sends nothing shorter than two words.

/redacao/ is now /desk/ and /entregas/ is now /admin/deliveries/. The editor: "these back office links/names still count in some words in Portuguese, and they should all be in English." The desk moved without trouble. The deliveries could not move anywhere: gate 13, the delivery quarantine, allows unapproved research items to be shown WITH THEIR STATE on exactly three path prefixes — /entregas/, /redacao/ and /admin/ — and build/gates.py is deny-listed, so no session here may add a fourth. It was proved rather than assumed: the index page passed at a new path and the two delivery detail pages went red on six items. /admin/ is one of the three, it is already English, and build/build.py already puts /admin/review/ there for exactly this reason. So the deliveries went under it. redacao/issues/, redacao/runs/ and redacao/correio/ stay where they are — the same file pins those three paths in the write-boundary gate — and redacao/ is therefore still a folder name in the console's file links. That is the honest remainder, and issue 008 already asks for the one line that finishes it.

/en-gb/ is 224 pages at 100% coverage, and the number on each page is the real one. The interesting part of this release is not the translation, it is what the coverage figure had to survive to become true. It said 100.0% three separate times while an English page still read «Empresas Protagonistas Instituições Políticas», and each time the cause was a different class of blindness:

* A metric that could not see what a reader complains about. A single accented word is not translatable by the rules — one word is usually a name — so it never reached the substitution and was never counted as anything at all. i18n.parece_portugues() now counts what a reader would SEE, whether or not the pipeline is willing to translate it. Coverage fell from 100% to 78.3% and became worth reading. * generated used as a dustbin. A date line, a count and a byte total are formatted rather than translated — but «there is a template and nobody has translated it yet» is a MISS, and filing it under the column that does not count against coverage is how a report reaches 100% with a Portuguese page in front of it. generated now means only: no template exists. * A machine-fact test that matched «Aviso». The pattern allowed «any single token», which is every one-word label on the site. All of them were classified as identifiers and left Portuguese in every locale, and the report counted them as correct. A machine fact now needs a machine shape: a separator, a digit, or hexadecimal length. * A name harvest that swallowed the site's own vocabulary. nome and rotulo were taken from every data file — and the three departments are agents whose nome is «Pesquisa», «Redação» and «Verificação», the eight sections carry a rotulo in the ontology, grafo.json labels this site's own headlines, and lexico.json labels the themes. 16 858 runs were classified verbatim, including the masthead. A name is a name because a SOURCE published it, not because a field is called nome.

Generated text is now formatted rather than left alone, and that is one segment per template for ever. i18n.mascarar() replaces every run of digits with # and every month or weekday name with @; the masked form is the segment, and the values are put back at render time from the page itself. «Lisboa · segunda-feira, 14 de setembro de 2026» is one template that serves every date this site will ever print, and each locale carries its own month and weekday names in dados/i18n/locales.json. Ordering matters and was got wrong twice: the mask has to be tried before the «is this prose?» test, because «mais 3 entidades» is not prose by that test and was being reported missing with its own translated template sitting unread in the memory.

Two masking mistakes worth remembering, because both produced hundreds of junk segments. Identifiers were masked too — 2026-09-14/oradores/luis-valente became a «template» and 465 version badges became v#.#.# — which buried the fifty templates that are real. And a sentence that STARTS with a name is one entity's sentence, not a template: «Nina Chandé — Pessoa no grafo do ecossistema português de IA. 4 arestas» masks to something still different for all 198 entities. Both are now refused by shape.

The language switcher points at THIS page in the other language, not at the other language's front door. A switcher that always lands on the home page makes a reader four levels deep pay for changing language by losing their place, and it is the commonest way multilingual navigation is got wrong. The hrefs are relative and correct in the Portuguese tree; build/locales.py recomputes them per locale from data-lang, because a relative link cannot be right in both trees at once and the site gate cannot resolve a root-absolute one. Codes, not flags: a flag is a country and a language is not a country.

Four defects the gates caught before a reader could.

* An orphan translation was being served. Gate 44's own comment claimed orphans are «kept for reuse, never served», and substitution looks up the hash of the text ON THE PAGE, so a translation left behind by a rule change was still being found. «sessão de arranque (Claude Code, Opus 5)» went out translated on 224 pages and the gate named it. Serving only keys the CURRENT extraction knows makes the claim a property of the code instead of a comment. * The entity linker was inventing links. Running it over a translated block added links the Portuguese page does not have, and each call carried its own «already linked» set, so one name became four links to one entity on a page whose published formula allows one. Gate 24 caught it on four pages. A block is now a segment only when its content is plain text — which also stopped the footer's hand-made link to /aviso/ being deleted by a translation, a link gate 14 requires. * The link rewriter broke sibling files. It prepended one ../ to anything leaving the localised set, which is right for a link written from the root and wrong for comentarios.json beside an article. Resolving to a root-relative path and re-relativising is right in both cases. * The boxed language badge moved the chrome. It added 3px to the paper's utility strip and not the console's, and gate 33 requires the two to be identical — the panel must not move when a reader crosses between them. The browser gate measured 36 against 33. No border, no padding, and the console gets the run too.

build/chrome.py now runs a second time, after the locales. It writes llms.txt and sitemap.xml by globbing the pages that exist, and on a first build of a clone the locale tree does not exist yet — the site gate then reports that llms.txt does not name /en-gb/, and rule 7 says the sitemap agrees with the tree.

What is still Portuguese on an English page, and why. Every excerpt, every name, every verb of the graph — by rule, and gate 44 asserts they are byte-identical. The article headline of a story is translated; the frozen excerpt inside it is not, and the page says which is which. French and German sit at preparado with the machinery proved and the words not yet written.

CLAUDE.md still says «Everything a reader sees is European Portuguese under AO90». The editor authorised publishing in conversation and the deny list means the file itself is theirs to amend. The wording is in redacao/issues/010 and the site is now ahead of its own constitution — which is worth saying plainly rather than leaving for somebody to notice.

admin/versions/v0.23.9.md — this note as markdown, which is what it is.

v0.23.82026-09-22

The scheduled newsroom run has never run, and it failed identically every day. The operating model of this site rests on one pass of the loop a day at 06:30 UTC, with the editor of record as the gate on everything that goes live. The cron fires. The run never starts. Runs 5, 6, 7 and 8 of the newsroom-run workflow are all failure, all in the same place, all before the first step of the loop:

`` Action failed with error: Environment variable validation failed: - Either ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, or workload identity federation (ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID) is required ``

secrets.ANTHROPIC_API_KEY resolves to empty, so the repository secret is not set. This is not a run that goes wrong; it is a run that does not happen. Nothing in redacao/runs/ came from a schedule — every record there was written by an interactive session. And the way it hides is the part worth writing down: on the Actions tab, a cron that fails identically every day looks exactly like a cron that is working, because the cadence is the same. Four days of failure went unnoticed for that reason alone. A secret cannot be set from inside a run and should not be, so it is the editor's line; redacao/issues/011 has it, with the verbatim error.

Two defects in the workflow itself, found while reading the failure, and both fixed here.

The workflow installed pyoxigraph and nothing else. The deploy job installs requirements.txt; this one did not. Without pycryptodomex, build/pdf.py decrypts every stream of an AES-encrypted diploma to zero bytes and states «nenhuma camada de texto foi encontrada — é provavelmente um PDF digitalizado» — a wrong statement about somebody else's document, made with every gate green. Without jsonschema, build/entregas.py validates no delivery at all. This repository has already been burnt by exactly that: one frozen PDF read 30 808 characters on one machine and 0 on another, hashes matching, every claim resting on it quietly turning to fonte_inacessivel. A scheduled run that reads evidence differently from the machine that released it is not reading evidence.

The prompt interpolated ${{ github.run_started_at }}, which is not a property of the github expression context, so it rendered as nothing: every scheduled firing has been telling the session "Today is (convert to Europe/Lisbon)" with the date missing. This run is date-driven end to end — the freeze directory, the register row, the run record — so an empty date is not cosmetic, it is a run that has to guess what day it is. The date is now computed in Lisbon in a step of its own and passed in as an output.

Neither of these would have been found by looking at the code. They were found by reading the log of a job that had been failing for four days, which nothing in this repository was watching. That is the third item in redacao/issues/011: decide whether a red scheduled run should tell anybody.

admin/versions/v0.23.8.md — this note as markdown, which is what it is.

v0.23.72026-09-22

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.

admin/versions/v0.23.7.md — this note as markdown, which is what it is.

v0.23.62026-09-18

The four questions move to the top of the page, where a thumb can reach them. This page gets opened one-handed, in a hall, thirty seconds before a conversation starts — and the four fixed questions were the third pack down, behind two packs about ChatGPT interviews that nobody is reading while a founder waits. They are now the first thing on it: numbered, English and Portuguese stacked so the interviewer reads whichever the person in front of them speaks, each with the one line saying why it is asked that way. The closing question and the thirty-second read-back sit under them.

They are rendered from one file, not retyped into a second place. 00__the-spine.json holds the four questions and the closing one; the top card renders from it and the two question cards keep their long-form treatment. Four sentences are a small thing to copy and exactly the kind of small thing that drifts — one gets sharpened in the card, the card is what gets read at the event, and the page at the top quietly goes on saying the old version.

Gate 43 makes that impossible. Every question in the spine must appear verbatim in both question cards — English in the English card, Portuguese in the Portuguese one — and on the rendered page. Gate 42 already tied the cards to the page; this ties the spine to the cards, so the chain is closed at both ends.

Broken on purpose in both directions before it was trusted: a question edited in the spine and not in the card went red naming the card, and a question edited in the card and not in the spine went red twice — once from gate 42 for the stale hash, once from gate 43 for the drift. And the language gate caught this gate's own section heading, which I had written in Portuguese inside an English file.

admin/versions/v0.23.6.md — this note as markdown, which is what it is.

v0.23.52026-09-18

A question card for interviews you conduct yourself, at an event, with a recorder running. The third interview pack, and the only one where a human does the interviewing. Pack 09 assumes half an hour and a quiet room; a summit gives you five minutes, standing, in a hall with a PA system, with somebody who has a meeting at the top of the hour. Different constraints, different instrument.

Four fixed questions, asked identically to everybody.

1. In one sentence, what do you make, and who pays for it? 2. What can you do today that you couldn't do eighteen months ago? 3. What's the hard part — technology, money, or people? (make them pick one) 4. What does everyone say about AI in Portugal that doesn't match what you see?

Improvising the spine feels better in the moment and destroys the comparison. Asked identically, twenty conversations become something you can count — how the hard part splits across twenty founders, what capability actually changed in eighteen months, whether they disagree with the consensus about the same thing. Counting is the one thing this newsroom can do that a blog cannot.

And the closing question, which is the strongest thing in the pack: "who else here should I be talking to, and why them?" It draws a map of the room from the inside, and it is the question easiest to skip at four in the afternoon.

The card exists in English and in Portuguese, and it renders at 390px because it will be read on a phone in a hall, which the browser gate checks.

The first content on this site born in one language and published in another. The recording is in English; the site publishes European Portuguese. The pack proposes the rule and empresas/issue 009 asks the editor to make it one:

- The transcript is never translated. It is evidence, and evidence is not translated — the same rule that keeps a frozen page's bytes untouched. - A translated quotation carries its original, or it stops being a quotation and becomes a summary in our words, unquoted. This publication's whole claim is that what sits between quotation marks is what was said. - The interviewee approves the Portuguese, not only the English. They are being quoted in a language they may not have chosen. - The article says, in its provenance, which language the conversation was in.

There is no ninth section, and that was not a choice. The editor asked for a "Portuguese Startups" section; CLAUDE.md fixes the brief's eight and is deny-listed. A startup is a company and empresas is the brief's section for companies — so the interviews land there as a series, which is a shape inside a section rather than a section of its own. Issue 009 carries the naming decision, and notes that a series name is read by a reader and is therefore Portuguese.

What a founder says about their own company is a primary source. What they say about the market is a lead to verify, exactly like a line in a research delivery — a number said confidently at a summit is still a number with no source behind it.

admin/versions/v0.23.5.md — this note as markdown, which is what it is.

v0.23.42026-09-15

The operations console moves to /newsroom/, which is where the editor says every admin action belongs. Eleven pages — the console index, mail, board, bridges, team, agent activity, design review, guidance and documents, plus the six guidance documents — left /backoffice/. Their builders went with them: build/backoffice.py and its three siblings are now build/newsroom.py, newsroom_bridges.py, newsroom_design.py, newsroom_team.py. The navigation label a reader sees changed from Back office (EN) to Newsroom (EN).

The interviews page became a console page rather than moving as-is. It had been built with the newspaper's chrome and simply relocated, and gate 19 caught that immediately: any page in the console namespace must declare lang="en" and carry the notice saying this is not the publication. The gate was right and the page was wrong — it is about how this newsroom works, not about what it found, which is the same test that put the other eleven there. It is now /newsroom/interviews.html, built by the console's own shell, in the console's rail under Reference.

What is left under /admin/, and why each thing is still there. The question was asked directly, so here is the whole of it:

| What | Why it has not moved | |---|---| | admin/review/index.html | Gate 13 permits unapproved delivery items to be shown with their state on /entregas/, /redacao/ and /admin/ only. One line in build/gates.py adds /newsroom/. Deny-listed. | | admin/versions.html | admin/build/validate.js reads this exact path to check the release table has one row for the current version. Deny-listed. | | admin/build/version.txt | Same file, line 54, same problem. It is also read by CI and by before_push.py. | | admin/build/validate.js, render.mjs | The toolchain, not an admin action on the site. It can stay; moving it means editing CI and the deny-listed file that names itself. |

So /admin/ survives on three lines in two files that no agent here may edit — and that is the design working, not failing. An agent that can edit the gate that stops it has no gate. Issue 008 carries the exact changes; none of them was worked around by reaching for Bash, which would be the same edit under another name.

The browser gate earned its keep again. The interview prompts rendered as <pre> blocks with no wrapping rule — .bo-pre was a class I invented and never styled — and the page came out 937px wide in a 390px viewport. No other gate looks at width. A prompt that needs sideways scrolling on a phone is a prompt nobody copies on a phone, and copying is the entire point of that page.

Old release notes that linked into /backoffice/ were repointed, because the site gate requires every link to resolve. Their prose was left alone: a note saying a page was at /backoffice/… in March was true in March, and rewriting the record to match today would be the one edit this repository never makes.

admin/versions/v0.23.4.md — this note as markdown, which is what it is.

v0.23.32026-09-15

The editor was right: the operational surface was still speaking Portuguese in its addresses. /redacao/revisao/ and /redacao/entrevistas/ were pages about how the newsroom works, not about what it found, and the one test that decides these questions — would a visitor read this string on the site? — says English. The map drawn in v0.22.0 already agreed and nobody had noticed: redacao/ is not in its list of reader URLs that stay Portuguese, and its subfolders are already mapped to newsroom/.

What moved. /redacao/entrevistas/ → /newsroom/interviews/. build/revisao.py → build/review.py, build/entrevistas.py → build/interviews.py, dados/revisao.json → dados/review.json, dados/entrevistas.json → dados/interviews.json, the pt-decisoes component → pt-decisions, briefs/pack/09__entrevistas/ → 09__interviews/ and 10__entrevista-ao-editor/ → 10__editor-interview/, with English filenames inside both. Every key in the two data files, every identifier in the component, and the prose of both pages. The quoted evidence stays Portuguese — a claim, an excerpt, an article title and a decision comment are values, and the map renames vocabulary and never content. So do the two .pt.md interview prompts: they are pasted into a Portuguese conversation by a Portuguese speaker.

And one thing did not move, which is the honest part of this release. The editor's review page went to /admin/review/ and not to /newsroom/review/. Gate 13 — the delivery quarantine — permits unapproved research items to be shown with their state on exactly three surfaces: /entregas/, /redacao/ and /admin/. This page is that kind of surface and shows exactly that, so /admin/ is the exemption used for its stated purpose rather than worked around. The right address is /newsroom/review/, and reaching it needs one line added to a list inside build/gates.py — which is deny-listed, because an agent that can edit the gate that stops it has no gate. That line is the editor's, and issue 008 asks for it, with the exact change.

What was not done, and was considered. The page could have been made to pass at /newsroom/review/ by not printing each item's headline, since that is the string gate 13 matches on. That would have been gaming the gate: the headline is the question the editor is being asked, and a page that hides what it is asking about to satisfy a check is worse than a page at a second-best address. If the place is not permitted, move the place or change the list — never change what the page says.

Rewritten rather than find-and-replaced. Halfway through, a bulk rename produced save(estado), this.items = itens, and a builder calling a variable that no longer existed — exactly the half-translated identifiers v0.22.0's dry run found 120 of. Both builders and the component were rewritten from scratch instead. A repository-wide rename reads like a find-and-replace and is not one.

Renames are recorded in dados/en-migration.json under done_since_the_map, so the next session sees what was already done and why the one exception exists.

admin/versions/v0.23.3.md — this note as markdown, which is what it is.

v0.23.22026-09-15

The release rules stop living in one session's head. Everything this week's work learned the hard way is now a guidance page, releasing.md, rendered at /backoffice/guidance/releasing.html and linked from .claude/ONBOARDING.md — which is the file a session is pointed at on startup, and therefore the only place a lesson reliably reaches the next agent.

Which component is the minor, stated once and for good. The version is v<release>.<major>.<minor> and the minor is the third. Every push to dev is a minor release, so a normal one goes v0.23.0 → v0.23.1. CI is the authority and agrees: its tag job computes NEXT_MINOR as the third component plus one and rejects anything that is neither that nor a deliberate major. build/before_push.py advises the major and is wrong about it — it is reliable for whether a number is taken, not for which to take. That disagreement is now written down, in the guidance and in the onboarding file, instead of being rediscovered by whoever releases next.

And the three other things that fail quietly. Install requirements.txt first: without pycryptodomex an AES-encrypted diploma from the gazette reads as zero characters and is reported as «provavelmente um PDF digitalizado», a false statement about somebody else's document; without jsonschema a research delivery is not validated at all. Both leave every gate green. How to run the browser gate in a container that has no Playwright, with the exact commands, since installing it into the repository turns the site gate red. And the two merge commands that delete work without raising a conflict — git checkout --theirs over the unmerged list, which overwrites resolutions written to disk but not staged, and a clean auto-merge, after which you grep your own changes back one at a time.

The editor gets interviewed too. briefs/pack/10__entrevista-ao-editor/ is 09__entrevistas/ pointed the other way: instead of an assistant interviewing somebody outside the newsroom for material, an agent has ChatGPT interview the editor of record for direction — what to work on, what to drop, what he thinks matters. An agent knows what it has in front of it and does not know what the editor thinks is important; that information exists and today only surfaces when he remembers to write it down.

The part that makes it worth twenty minutes is that the agent reads itself first. Before there is any interview, the agent runs the preparation prompt over its own ROLE.md, MANDATE.md, open and blocked issues, unread mail, run records, and the release notes describing defects in its own domain — an agent that does not know what it broke repeats it. Out of that comes a state block carrying its real open questions, each with its own proposal. The difference between "what should I do next?" and "issue 006 has been stalled three days waiting on a PDF I cannot find; I see three routes and I would take the third" is the difference between a polite conversation and a decision in ten seconds.

Decisions and ideas come back in separate sections, and are treated differently. A decision becomes a file, with the editor's reason quoted verbatim, because the reason is what lets the next similar case be decided without asking again. An idea opens an issue in proposto and nobody starts it — an idea treated as an order is how an agent does work nobody asked for. Anything the editor asserts about the world is a lead to verify like any other; his saying a name is not a source. And nothing said aloud to a model puts a story into publicado, widens the folder an agent may write in, or excuses a gate.

Both packs render on one page. /redacao/entrevistas/ now carries eight prompts in two groups, each from its markdown source, still under gate 42 — which was written for one pack and covers the second without a change, because it checks the relationship and not the list.

admin/versions/v0.23.2.md — this note as markdown, which is what it is.

v0.23.12026-09-15

The editor gets a page that asks him the questions, instead of six pages he has to read. Coming back after a day away meant reconstructing, from the release notes, the delivery pages, the board, the mail, the revision files and the articles, what had happened and what was now his to decide. Every one of those is honest and none of them is a briefing. /redacao/revisao/ gathers the open questions — and only the ones whose answer is the editor's to give — with the file each answer has to end up in and the agent that will put it there. Today: six research items, one blocked issue, one ontology proposal.

It captures answers and it writes nothing, and it says so on the page. A verdict, a comment and a reaction per question, kept in localStorage so a review can be interrupted and resumed, and then handed back as a block of text addressed to the agent that will do the work — copy all, or copy just what goes to one agent. This site is static and cannot write to git, and a console that looked like it recorded a decision while recording nothing would be the one dishonest page on a publication whose whole argument is that nothing is asserted without bytes behind it. The browser holds the editor's words; an agent turns them into files; nothing is a decision until the file exists. A question with no verdict stays por rever, which is what the quarantine gate already enforces — so the safe state and the default state are the same state.

And the page does not pretend to know when your last review was. It lists when each thing happened and leaves "have I seen this?" to the browser, because that is knowledge about one person on one device, not a fact about the newsroom.

The interview method, and the prompts that do it. briefs/pack/09__entrevistas/ is the other half of what this newsroom publishes: 08__research-briefs/ sends an assistant after documents, this sends it into a conversation. Four pieces — the interviewer prompt in Portuguese and in English, the topic block that is the only part that changes, and the prompt an agent is given to assemble a pack for one named person. A person too busy to write two pages about their work will talk for twenty minutes about it, at an hour that suits them, by voice or by typing.

The rules do not bend for it: an interview is a primary source about the person speaking and nothing else, so what they assert about the world is a claim to verify and not a fact for having been said aloud; three fields per person as everywhere else on this site; no contact detail in any file; the interviewee approves the report and the editor of record decides publication, two decisions neither of which substitutes for the other; and removal on request stays unconditional and carries no reason.

/redacao/entrevistas/ renders those prompts rather than linking them, and gate 42 makes that safe. A prompt is copied, not read — by someone about to paste it into ChatGPT, often on a phone — and sending them to a raw markdown file to select-all is how you lose them. So the page is a second VIEW of one copy: rendered from the markdown on every build, marked derived, and checked. A derived file with no gate is a second copy with extra steps, which is what gate 28 learned from the agent mandates. Gate 42 was broken on purpose in both directions before it was trusted — a hand-edited page and a source changed without a rebuild — and its first version passed the hand-edited page, because it sampled a single line. A gate that checks one line certifies one line. It now samples across the whole file.

One latent bug, found by the browser gate. The entity linker turns a name's first mention into a link, and it protected <a> and <code> but not <script>. The review page carries its questions in an inline <script type="application/json">, and the linker reached inside and rewrote entity names there into <a class="ent" href="…"> — well-formed HTML, and JSON with unescaped quotes in the middle of a string, so the component failed to parse it and declared itself in error. No other gate could have caught it: the page was valid, the damage was inside a string, and only opening it in a browser showed anything wrong. The linker now skips <script> and <style>, which is what it should always have done for any page carrying inline data.

Numbered v0.23.1 and not v0.24.0: every push to dev is a minor release, and the minor is the third component. build/before_push.py says to take the next second component, which disagrees with CLAUDE.md and with what CI's tag job computes as NEXT_MINOR; the editor's instruction settles it, and the tool's advice is noted for whoever owns that file.

admin/versions/v0.23.1.md — this note as markdown, which is what it is.

v0.23.02026-09-15

The messages now say where things actually are. v0.20.0 renamed the back office's addresses to English and deliberately left redacao/correio/ alone, on the grounds that a message is immutable in Email-FS-lite. The editor overruled that, and was right: "email-fs-lite is convention-based and it is to make changes like this, especially at such an early stage — since leaving it is worse due to the technical debt that it will create."

So four messages — each in both of its copies, the sender's in saida/ and the recipient's in expedicao/ — plus three run records now name board.html, bridges.html, design.html and the renamed generators instead of addresses that stopped existing three releases ago.

The interesting part is not the sweep, it is that the rule had to be rewritten to allow it. Immutability here was stated flatly in two places — redacao/correio/LEIA-ME.md and the mail page — and this repository's whole argument is that a rule the record quietly contradicts is worse than no rule at all. A commit that edited four "immutable" messages while both of those still said "never edited" would have been exactly that.

redacao/correio/LEIA-ME.md therefore carries the exception, its conditions and its log:

- Immutability here is a discipline, not a mechanism. Nothing in a git repository stops a write. The protocol is convention-based, so the convention can be changed — and if it is, it has to be said. - Only the editor of record authorises a sweep, and the authorisation goes in the commit subject. - A sweep is mechanical and uniform, and must not change what any message asserts. Swapping a renamed path: yes. Rewriting a sentence, correcting a number, softening a conclusion: never — that is what a reply and In-Reply-To are for. - Both copies move together. A message lives in the sender's saida/ and the recipient's expedicao/ or entrada/, and the two must stay byte-identical. Editing one is how the model actually breaks; both pairs were diffed after this sweep and match. - The log is a table, with what each sweep changed and what authorised it. This is its first and only row.

And the regime change is written down rather than left to be rediscovered: this is cheap now because nothing outside the repository cites these messages. The day one of them is quoted from outside, a sweep acquires a cost it does not have today, and that section is where that gets recorded.

The mail page carries the same qualification, and deliberately does not print the version the last sweep happened in — it would have been rendered from version.txt, which means the page would have claimed the last sweep happened in whatever release you happen to be reading. The protocol note holds the number; the page points at the note. Content exists once.

Also in this release: nothing else. The sweep, the rule it needed, and this note.

admin/versions/v0.23.0.md — this note as markdown, which is what it is.

v0.22.02026-09-15

The editor's point is right and larger than it looks. dados/ is Portuguese leaking into the backend, and with more language editions coming the data layer has to be language-neutral: a Portuguese key is fine while there is one edition and wrong the moment there are two. This release does the preparation, proves it, and stops at a wall it may not climb.

dados/en-migration.json is the map, and it is data — because this repository's rule is that a classification is a published formula or it does not happen. 467 keys and 8 folder renames, and, as importantly, what it does not touch, each with a reason: reader URLs (artigos/, entidades/, empresas/ — in a multilingual future those are the Portuguese edition's paths), the ontology's verbs (rule 6: the graph reads aloud in Portuguese), the entity type names that appear in those URLs, and every value in every file. The map renames the vocabulary, never the content.

build/migrate_to_english.py applies it, and refuses to. Dry run by default; --apply checks the blockers first, prints what the editor has to change, and exits non-zero rather than doing four-fifths of a rename.

## What running it four times against a scratch clone found

A repository-wide rename reads like a find-and-replace. It is not, and the only way to learn that cheaply was to do it somewhere it did not matter. Six classes of defect, every one of which would have reached the live site:

1. Folder renames applied child-before-parent. Renaming fontes/congeladas first creates sources/, so the later fontes → sources is skipped as "target exists" — leaving an empty fontes/ and the move half-done. 2. The pattern matched "dados" but not "dados/historias.json", nor "fontes/congeladas" with no trailing slash. The second is how nearly every builder refers to a folder. 3. The key map was built from dados/ alone. Keys that live only in artigos/**/comentarios.json were renamed in the code and not in the data. 4. 120 keys came out half-translated. porque_existe_este_ficheiro → why_existe_este_file; nao_e_aconselhamento_juridico → not_and_aconselhamento_juridico. They are phrases, not compounds. All 120 are now written by hand, and a rule that got those wrong is not trusted with the other 469 unchecked either. 5. redacao/ is a reader URL, not a backend folder. redacao/index.html is the newsroom-floor page. Its subfolders move to newsroom/; the page stays. Found because the folder survived the migration holding exactly one file, and that file was a published page. 6. Portuguese Python identifiers are invisible to a string-level rename. junta(agente=…, especie=…, porque=…) builds a dict whose keys the map renames — so the function is handed agente and reads agent. 126 of them in build/. Unsolved, and the largest piece left; it is code-only, and needs Python's own parser rather than a regular expression.

## Why it stops here

build/gates.py and build/entregas.py hardcode the backend folder names and 115 of the keys, and both are in the deny list of .claude/settings.json. An agent that can edit the gate that stops it has no gate at all — so no agent working here may touch them, and renaming around them turns the build permanently red through files no agent here can fix. dados/aviso.json, also deny-listed, holds 33 more.

That is a permissions decision, and it belongs to the editor of record. The note is in redacao/correio/editor/entrada/, with the two ways out and what each costs. A run that stops honestly is a good run.

Nothing was renamed in this release. The map, the script, the editor's note and this page are the whole of it — and docs/guidance/language.md, which has said since v0.10.0 that this migration must happen alone in one release, now links to the map and says how big "alone" turned out to be.

admin/versions/v0.22.0.md — this note as markdown, which is what it is.

v0.21.02026-09-15

A session starting work on this repository now lands on something. There was no briefing: Claude Code loads CLAUDE.md and nothing else, the guidance existed only as markdown in docs/guidance/, and the one route to it on the site was /backoffice/docs.html#docs/guidance/language.md — a fragment, which is not an address. It cannot be cited in a commit message, it is not in the sitemap, and anything fetching it gets the shell of a document browser rather than the document. llms.txt, the file this site tells machines to read first, mentioned the guidance zero times — an agent arriving to change the site found no route to the rules it was about to break.

Every guidance document now has a page of its own, at /backoffice/guidance/<name>.html, rendered from the markdown by build/guia.py on every build. They are in the back office because that is where English pages live and where gate 19 already checks they carry lang="en" and the box saying they are not the publication. llms.txt gains a section pointing at all five — in English, unlike the rest of that file, and it says why in the file itself.

.claude/ONBOARDING.md is the short form, and build/briefing.py prints it from the SessionStart hook, so it reaches every session without being asked. It reads the version, the next free gate number, the agent register and the last run record off disk rather than restating them — a briefing with a number typed into it goes stale on the day it matters most. The permissions block of .claude/settings.json was not touched, and the change asserts that rather than promising it: CLAUDE.md forbids a run from widening its own permissions, and a hook is not a permission.

Gate 41 keeps it true: every document under docs/guidance/ has a page, llms.txt points at them, and every link in the briefing resolves. A briefing whose links have rotted is worse than no briefing, because it reads as current.

Three bugs in the shared inline renderer, found by rendering prose that had never been rendered. Backticks were not handled at all, so every like this reached the page as literal backticks — invisible on an article, which rarely uses them, and everywhere on guidance pages, which are mostly file names. Because nothing protected a code span, a [[fonte:…]] written inside one, the syntax being discussed, became a live chip pointing into the register at an id that does not exist: a page explaining how a citation works was making one. And *italics* came through as asterisks. Fixed in paginas.inline() for every page on this site, not worked around in the new renderer.

Gate 19 got narrower, which made it more accurate. It failed the guidance page for carrying a source mark — correctly, by its old reading. A mark inside <code> is the syntax being quoted, not a citation being made, so it is now skipped; an unquoted mark still fails, and the check that matters — a back-office page linking into the register — was never in question. Verified by injection: an unquoted [[fonte:dados-gov]] on a console page still turns the build red.

Markdown links were rendering for the first time, and the site gate immediately found nine broken ones — links that had been correct between two markdown files and were wrong the moment one of them became a page two directories away. One, llms.txt in index.md, had simply been wrong since it was written and nothing had ever rendered it.

And the gate that catches what this release is about caught this release. Gate 36 — added four versions ago and never yet fired in anger — went red on the merge: another session had taken 39 in build/gates_desenho.py while this one took 39 in build/gates_artigos.py. Theirs shipped first, so this one renumbered; then 40 was taken too, in admin/build/render.mjs, because that session had extended gate 36's own registry to cover the browser gate and the site gate — a registry covering some of the addresses is one that hands out addresses twice. This gate is 41.

---

Three things found on the way that were nobody's intent.

The browser gate caught two pages that drag sideways on a phone, both from the console rewrite two releases ago — it is a local check, not a CI one, so a session that does not run it ships without knowing. On the board, a status pill carrying a whole sentence («blocked on build/pdf.py está na lista de recusa; nenhum agente…») was white-space: nowrap and 538px wide on a 390px screen. On the mail page it was three pixels, from a URL long enough to have nowhere to break — and three pixels is enough, because the reader still has to pull the page back to read the next line. The back office is mostly paths and URLs, so long words now wrap there by rule rather than by luck.

Two article folders were left as orphan pages. uma-captura-nao-mostra-movimento and registo-nacional-nao-devolve-texto had their artigo.json, artigo.md, claims and provenance removed on the release branch when the article set was replaced, but their generated index.html survived this merge — two pages on the live site with a stale version badge and every link inside them broken. They are gone. The article gates could not have seen this: gate 16 walks artigos/**/artigo.json, so a folder with no artigo.json is invisible to it, and the derived index had already dropped both. The site gate found it, by following the links.

The min-width: auto trap, twice in one afternoon. A flex or grid child refuses to shrink below its content unless min-width: 0 is set, so no amount of wrapping downstream helps until it is. It is the load-bearing half of the status-pill fix, and it is worth knowing before the next thing on this site overflows.

Renumbered twice on the way out.* Another session released v0.20.0 while this note was being written, and had written its own admin/versions/v0.20.0.md; the same merge also turned up backoffice/viewer.html, a page whose generator they deleted in v0.17.0 and which this side's build had faithfully recreated — the third orphan of the day, and the same shape as the two article folders. And a release note from v0.16.0 pointed at /backoffice/desenho.html, renamed to design.html in their v0.20.0: historical notes keep their prose and get a working address, since it is the same page. **build/before_push.py named the collision before the push rather than after it, which is the whole of what it is for.

admin/versions/v0.21.0.md — this note as markdown, which is what it is.

v0.20.02026-09-15

The back office's addresses are English now, and nothing was left behind to say so. The editor settled the vault's open question 2: this site has no readers and no SEO yet, it is still in experimentation, so an address may simply stop existing — and what they explicitly did not want was a scatter of files whose only content is "this moved".

| was | is | |---|---| | backoffice/correio.html | backoffice/mail.html | | backoffice/quadro.html | backoffice/board.html | | backoffice/equipa.html | backoffice/team.html | | backoffice/pontes.html | backoffice/bridges.html | | backoffice/desenho.html | backoffice/design.html | | backoffice/viewer.html | deleted |

viewer.html is the one that makes the point. It had been kept since v0.3.1 as a permalink that forwarded to docs.html, carrying the #fragment across — a page whose entire content was the sentence "this page moved into the document browser". That is precisely the stub this release exists to not create five more of, so it is deleted rather than renamed. The rule it protected is untouched: docs.html is still the one and only implementation of document rendering here.

index.html is deliberately NOT renamed to console.html, which is what the proposal asked for. /backoffice/ is the address the rail links, the paper's footer links and gate 19 requires; a directory wants an index; and a console.html sitting beside an index.html that both had to exist would be the stub problem again, one directory down.

The generators took English names too, because a module called equipa.py that writes team.html is the same drift one level below the URL:

build/equipa.py → build/backoffice_team.py (team, board, mail) build/pontes.py → build/backoffice_bridges.py build/desenho.py → build/backoffice_design.py

and the build lines they print are in English with the rest of the code. The line drawn, and worth stating because it will come up again: the paper's machinery and data stay Portuguese — artigos.py, entidades.py, gates_desenho.py, dados/historias.json — because the paper is Portuguese and that is internally consistent. It is the back office's pages and the modules that write them that are English, because the back office is.

What was NOT rewritten, on purpose. Message bodies under redacao/correio/ and the run records still name the old addresses, and they still will. A message is immutable in this protocol — never edited, never deleted, only moved — and a run record says what happened; rewriting either to match today's tree would be falsifying a record to tidy a search result. Both mention the paths in prose rather than as links, so nothing dangles. Old release notes stay as written for the same reason: v0.16.0's note describes v0.16.0, where desenho.html was the correct address.

Gate 39 caught itself, which is the interesting part. The ratchet on the console's legacy layer held a hand-written list of four generator filenames. Three of them were renamed in this release, so the gate went looking for build/equipa.py, found nothing, and reported 1 of 4 instead of 4 of 4 — while staying green. That is the one way a ratchet fails completely: it would have let the count climb back to four without a word. It now discovers the generators by what they are rather than by a list somebody has to remember to update, which is the same lesson as gate 36 in v0.15.0 not seeing the two gates that live in JavaScript.

239 pages, every link resolving, all five gates green, 18 pages opened in a browser with no 404.

admin/versions/v0.20.0.md — this note as markdown, which is what it is.

v0.19.02026-09-15

The register was the end of the road, and it should have been a junction. Its own subtitle said a claim on this site walks back to here — true, and true in one direction only. You could land on /registo/#2026-09-14/entregas/…/src-pol-04, read the hash, confirm the bytes, and then have nowhere to go. The evidence is the foundation of everything this publication says, and from the foundation you could not reach the building.

Every frozen file now carries what stands on it. A new column, and four kinds of thing in it:

- the research delivery that named it, linked to that source's own row on the delivery page; - the articles whose assenta_em lists it, linked to the article; - the entities whose name was found in those bytes, with the number of occurrences, each linked to its entity page; - and the graph node grounded on it.

So the walk from src-pol-04 now runs: the government's own release → the ChatGPT delivery that proposed it → the published article about the €200M for AI Gigafactories → the four entities read out of those bytes, Saúde 9 times, Educação 4, Jurídico 3, Governo de Portugal 2. And the article's own [[fonte:…]] mark already linked back to the register row, so the loop closes in both directions.

Nothing in that column is written by hand. It is derived from the same files that build the pages — dados/historias.json, dados/entregas.json, dados/entidades.json, dados/grafo.json — so a link cannot disagree with the page it points at, and no new state has to be kept in sync.

One bug, caught by building it. The first version read the delivery out of each source's congelada path. That path names whichever capture the delivery was last frozen under, and drifts: a delivery re-frozen on 15 September had a path saying 2026-09-15 while its register id still said 2026-09-14, and the link to the delivery silently vanished — the other three kinds still rendered, so the row looked complete. The delivery is now read out of the register id itself, which is <capture>/entregas/<delivery-id>/<source-id> and is the stable thing. A row that is partly right looks exactly like a row that is right.

admin/versions/v0.19.0.md — this note as markdown, which is what it is.

v0.18.02026-09-15

A second delivery arrives, and this time it validates. ChatGPT's first research delivery failed the published schema on 45 counts — every one of them a closed list it could not read, because the brief sent it to an address that does not exist. The address was corrected in v0.17.0. This delivery, run against the corrected brief, validated on the first attempt, re-sent none of the eight pages the newsroom already holds, and carried 3 of its 4 claims through the byte check.

The delivery page becomes navigable. It was already dense — four stat tiles, the assistant's own notes, a source table with hashes and readability, an item per lead with every claim under it, and 41 queries at the end — and the only way to reach the item you cared about was to scroll. It now opens with a contents table: one row per item, its kind, how many of its excerpts were found in the frozen bytes, and the editor's decision, each row jumping to the item. Alongside it, chips to the sources, the queries and the decisions, and a strip linking the sibling deliveries — a part is one of eight, and a reader landing on one should not have to go back to the index to reach another. The frozen copies stay listed, hashed and unlinked: they are evidence, not pages of this site.

The assistant found a defect in the brief, and it was mine. v0.17.0 told it to omit delivery.vault until the vault exists. The schema lists vault in delivery.required and types it ["object", "null"] — omitting it fails. The delivery said so in its own notes, used null, and validated. The brief now says to send "vault": null, and says why it changed.

The freeze date was being read from the wrong place, twice. build/entregas.py --date defaults to today and re-checks each excerpt against fontes/congeladas/<date>/entregas/; point it at the wrong day and every source reads as unreadable with the build still green. v0.17.0 anchored it to the register's newest capture. That was wrong in two ways at once: the register is written by build/extract.py, which build/tudo.py skips unless --fetch was asked for, so it can be days stale; and the value was read at import, before that step could have run anyway. A freshly frozen delivery was checked against the previous day's directory, found nothing, and reported 0 of 4 sources readable — green build, silent loss. It now reads the frozen directories themselves, at the moment the step runs. Ground truth is where the bytes are.

What is still not readable. The EUR-Lex PDF of Regulation (EU) 2026/1744 answered HTTP 202 with no body, so the claim about Article 113 and the date of 2 December 2027 could not be checked against anything. And the Série I PDF of the RCM that approves the national AI agenda is still missing: the assistant spent fifteen of its forty-one queries hunting it and did not construct the address by guesswork, which is the correct behaviour and is worth recording as such.

admin/versions/v0.18.0.md — this note as markdown, which is what it is.

v0.17.02026-09-15

The first five articles are published, and the front page stops being about itself. Until now the three blocks a reader met first were the readability of the national register, an event that names its stages two ways, and a speaker list with one capture — three stories about this newsroom's own plumbing, honest and the only things the site could claim before it had published anything, and the wrong thing to put in front of somebody who came to read about AI in Portugal. Those three are gone, along with an empty container for the national AI agenda's legal instrument. In their place: the AI Act designation that is nowhere on the government's own AI page, the Gigafactories resolution and its €200M, €25M for AI in public administration, the Data Centres Plan read whole from the gazette's PDF, and the agenda with 32 initiatives whose founding act still will not open.

The rule held at every step. The editor of record decided ChatGPT's first delivery item by item: five approved, none rejected, three left undecided on purpose. A claim reached prose only when the excerpt was re-found in the frozen bytes AND the editor approved it; the claims that were not, were not written, and each article says which and why. The three undecided items all rest on a page that returns 2 346 bytes and 22 characters of visible text — rejecting them would have said the newsroom does not want them, and it wants them more than anything else in the delivery. Issue 006 unblocks all three with one PDF.

The research brief pointed at a schema nobody could fetch. It published the contract at newsroom.sgit.ai/briefs/pt-newsroom-pack/… when this site is pt.newsroom.sgit.ai serving briefs/pack/ — wrong host and wrong directory, in both briefs and in the schema's own $id. ChatGPT said so in its own delivery notes and then failed validation on twelve counts, every one a closed list it could not read. The address is fixed, the closed lists are spelled out in the brief's prose as well as in the schema, and the brief now lists the eight pages already frozen so a second delivery does not re-fetch them.

Counting which page lost the most claims produced the most useful rule. Nine of the ten unusable claims rested on one /dr/detalhe/ address, which renders by script. The same delivery's files.diariodarepublica.pt PDF froze 228 725 bytes carrying 30 808 characters, and both claims on it confirmed. Follow the record page to the Série I PDF and cite the PDF.

build/entregas.py was never in the pipeline, and putting it there found something worse. The editor's decisions live in redacao/revisoes/ and nothing in build/tudo.py read them: an approval written by a human reached no page, and gate 13 went on believing nothing had been approved. It runs now, anchored to the register's newest capture rather than to the clock. And that is how this turned up: the same frozen PDF, with the same SHA-256 re-verified by gate 1, read 30 808 characters on 14 September and 0 on 15 September. The bytes had not changed; the machine had. build/pdf.py does AES by importing pycryptodome, the package was absent, _aes_cbc returned None, Cifra.ok stayed True, and the reader concluded the diploma was «provavelmente um PDF digitalizado» — a false statement about somebody else's document, made with the build green, while both claims resting on it turned silently to fonte_inacessivel. The AES-256 path already names the missing package; the AESV2 path says nothing. requirements.txt now names both dependencies with the reason for each and CI installs them before the gates; the fix in pdf.py is behind the deny list and is the editor's, as issue 005. Evidence that depends on which machine reads it is not evidence.

Two duplicate renderers died. Publishing exercised a path that predated the dated folders: every published article was rendered a second time at artigos/<slug>.html from conteudo/<slug>.md, and the front-page lead linked to that dead address. Nothing had caught it because nothing had ever been published. Content exists once.

The archive is the complete list; the front page is a choice. /artigos/ groups everything published by the day it was published — a different date from the one in the path, which is the day the bytes were frozen, and both are shown. The chips under the lead headline now describe the lead's own sources, named by publisher and byte count rather than by the id the register carries.

This release was built as v0.10.1 to v0.10.3 while dev moved on — to v0.15.0, then to v0.16.0 — in another session. The work was merged forward and renumbered once, at the end — which is what docs/guidance/concurrent-sessions.md says to do, and the three conflicting source files were build/build.py, admin/versions.json and version.txt; every other conflict was a generated file and was resolved by rebuilding.

admin/versions/v0.17.0.md — this note as markdown, which is what it is.

v0.16.02026-09-15

The back office stops being a report and becomes a console. The design review's second part arrived as vault e54hfntq v0.5.0 and is about /backoffice/. Its one-sentence finding is that the console "is a report about the newsroom, not a console for operating it" — 7,058px of state across nine pages, with zero buttons, inputs, selects or textareas on eight of them. You could read what happened. You could not do anything. This release implements it. The disposition of all twelve §5 items, with what blocks the four that are not done, is at /newsroom/design.html.

THE QUESTION THE CONSOLE EXISTS TO ANSWER. When the editor opens the back office he is asking one thing: what is blocked on me? The old answer was a single cell in row 6 of the first table — waiting on a human — rendered in the base chip style, so it looked exactly like the five running chips above it. Meanwhile the mail page knew he had an unread message, the board knew an issue was chipped waiting on dinis.humano, and the pipeline knew stage 6 was stopped. Three pages each held a piece of the answer and the index showed none of it. pt-queue is now the first thing on the console and the only filled rank on it, and backoffice.fila() derives its items from all four sources: the editor's own board lane, cards on another agent's lane whose bloqueado_por is the editor, paper issues the formula assigns to the editor, and mail sitting in their inbox. 13 items, against the one the console used to show. There is no count attribute anywhere — the rail badge and the queue's own heading are both len() of the same list, so they cannot disagree. A number typed beside a list it claims to describe is the failure this whole publication exists to report.

FOUR RANKS INSTEAD OF 159 LABELS. The review counted 159 distinct chip labels across the back office — 158 on the board alone — and ten different ways of saying "a human is blocking this", the most important of which was the least visible of the set. assets/console.css defines one scale: rank 1 a human must act (filled, and the only filled rank), rank 2 an agent is blocked (outlined amber), rank 3 running (outlined neutral), rank 4 done (a tick and no box at all). Rank 4 losing its box is most of how a page stops having 158 chips on it. The board is at 25 ranks, and what was in most of those boxes — a section name, a priority, an effort estimate, an issue number, an agent id — is metadata now, in mono beside the thing it describes. Two mappings were backwards and were corrected at the generator rather than in the mapping: the design review's own parcial was rendering filled red, which under this scale means a human must act, and editor — the one disposition that does mean exactly that — was rendering as the quiet neutral outline.

THE STRIP DOES NOT MOVE, AND NOW IT CANNOT. The proposal replaces the whole back-office chrome, which would have moved the top-level menu on crossing over from the paper — the thing the editor asked in v0.11.0 that it stop doing, and which the first fix for a later layout bug quietly broke again. Both times it was found by looking at a screenshot. So site.css is still loaded in the back office, first, for the operator strip and for nothing else: the strip keeps the paper's rules and the paper's ground, console.css loads second and wins everything below it, and gate 36 opens both pages in a browser, reads the strip's box in each, and fails on a difference of more than a pixel. It reported y 8 → 0 the first time it ran, which is exactly the class of regression it exists to catch. The strip measures x=56 y=8 1168×33 on the paper and in the console.

One cost came with that decision and is paid in full rather than hidden: site.css's .nav rule lands on the rail's items, which carry class="nav" too, and brought justify-content: center with it — the rail rendered centred, every label at a different indent, which is how a navigation column stops reading as a column. console.css answers it with a closed reset of exactly the properties site.css's .nav sets, so a leak is impossible rather than unlikely.

THE PREAMBLE, SAID ONCE. The same 70 words opened 9 of 9 pages — roughly 300px of identical prose above every screen, which trains the operator to scroll past the top of every page, exactly where the urgent things live. It also broke this project's own rule that content exists once. It is now on guidance.html and nowhere else. That is not the rule being softened: gate 19 requires every back-office page to say it is not the publication, because this whole area is in English by the editor's decision and the condition of that exception is that a reader who lands here can see it. What every page carries instead is one sentence, in the rail rather than above the content, so it is permanently visible without ever being in the way. The gate is unchanged and still finds its string.

STRUCTURE, WHICH THE BACK OFFICE HAD NONE OF. Zero <h2> in the entire back office, and five of nine pages with no heading at all — section titles were .sect divs, which are styling and not structure, so nothing could outline these pages and Ctrl-F was the only way in. All 34 of them are real <h2> now. Every page has exactly one <h1> and it names the place: the console's used to be a sentence in the publication's voice — "What the newsroom has done, what it is waiting on, and who is allowed to do what" — which describes a page rather than labelling where you are standing. What still wears .sect is a field label inside a card, "Refuses", "Writes in", because promoting sixty of those would have made every outline useless a different way. The rail replaces eleven flat links with three groups, marks the current page with aria-current="page" rather than weight alone, and carries a count on the three items that can be behind — never a zero, because a count that is always there has stopped being a signal.

REAL CONTROLS, AND HONESTY ABOUT WHAT THEY DO. The bridges page's controls were the only real ones in the back office and were dressed as status chips, in the same box as "secret" and "in transit"; they are buttons and fields now, primary for keeping a credential and danger for forgetting one. The queue's action names the file it would write in its title, so an operator who does not trust a button can do the same thing by hand. But a button cannot write a file on a static site — there is no server to write it — so each one goes to the place the write happens: the document browser for a file, the mail page for a thread, the bridges page for a message to the newsroom. Nothing claims to have written anything. Making them act is the append lane's job, the lane is built, and it has no credentials: that is §5.10, and it is the editor's.

THE MAIL MODEL, SHOWN RATHER THAN EXPLAINED. The review called the mail protocol the best thing in the back office and its presentation the weakest: a paragraph and a four-row table for something that is a path a file walks. It is the path now — saida/ → expedicao/ → entrada/ → tratado/, each folder carrying the rank its state means — and it needs no paragraph. The protocol's own table is still published, folded into a <details>, because the detail is real and is not what the page is for. The mailbox table gained a state column and the editor's row is marked on the row: a state marked only inside a cell is one you have to hunt for by reading every row.

THE LEGACY LAYER, AND THE RATCHET ON IT. Nine generated pages emit the paper's class names, and rewriting every generator in the release that adopted the console would have been a much larger diff than the review. So console.css maps those names onto the console's own — lossily, and in the direction the review asks for. It is a bridge with a declared end state, and gate 39 is what makes that more than a promise: it counts the generators still emitting the paper's classes and holds the count to a ratchet, which may fall and may not rise. The first version of that gate counted usages in the built HTML and went red on the very next commit, because that commit added twelve items to the design-review page. The number had gone up for the right reason and the gate could not tell. A metric that rises when the newsroom publishes more is not measuring the bridge.

Two defects in the adopted files, found by the gates. pt-queue.css hardcoded #fff and #000, which gate 33b rejected — the same finding the review itself had made about the five shipped components, in the component the review shipped. pt-queue.js reported a read failure by telling the operator to look at "the list below" while its own stylesheet hid that list; a read failure now un-hides it. And a third, introduced here and caught at 390px: copying white-space: nowrap from the rank scale onto the legacy .chip, which is whatever a generator put in it — including a whole sentence — pushed the console 75px past the viewport.

Not done, and why. Four §5 items are open and each says what blocks it on the design page. The filenames are not renamed to console.html / mail.html / board.html: those addresses are published in llms.txt and the sitemap, this site's own argument is that a link resolves, and whether ten redirect pages are worth the consistency is the editor's call. The vault channel's reply box is not built, because it needs three credentials this repository may not hold. The Portuguese left in the running prose is quoted — an agent's mission, a card's own reading of its work, the formula's porque column — and paraphrasing a record on the way to a screen is how a console starts to drift from the files it reports on. And the review's ~2,600px page height would mean deciding which of the roster, the articles, the sections, the deliveries and the run records the console stops showing, which is an editorial decision and not a design one.

admin/versions/v0.16.0.md — this note as markdown, which is what it is.

v0.15.02026-09-15

Three Claude Code sessions work on this repository at once, and until now nothing here knew that. A gate reads the working tree, and the working tree is one session's opinion. Everything that has gone wrong between sessions went wrong in the gap between my branch is green and my push is accepted. This release closes as much of that gap as a repository can, and says plainly which part it cannot close.

Gate 36 — no two gates share a number. Two sessions added gates the same afternoon and both started at 27. The build was green on each branch and stayed green after the merge, because a gate number is a comment: nothing reads it, so nothing checked it, and for a while this site had two gate 27s saying different things. The scan is deliberately tolerant, since three gate files write their numbers three different ways — and it caught its own author first: the block adding gates 36 to 38 did not match any pattern, so the gate reported «next free: 36» while sitting under a heading that reads 36-38.

Gate 37 — a class a component puts on the document has a rule in the stylesheet. This one is the interesting failure. <pt-chat> sets pt-chat-aberto on <html> and assets/site.css turns it into a column. The v0.13.0 merge brought a wholesale rewrite of that stylesheet; git auto-merged it with no conflict at all, and the four rules went with it. Nothing failed — the component still loaded, still reached pronto, still had a shadow root full of text — and the panel simply lay across the article again, the same symptom as the hidden bug two releases earlier from an entirely unrelated cause. A coupling that crosses a file boundary and is checked by nothing will eventually be deleted in silence.

Gate 38 — the release history is a set, and the version is its newest member. Two sessions took v0.13.0 within the hour. The site gate checks the table has a row for version.txt; it never checked there was only one, nor that the number was ahead of every other, nor that the note file named actually exists — and build/versoes.py skips a missing note silently, which turns a mistyped path into a release shipping with an empty row.

build/before_push.py asks the one question a gate cannot: what is on the release branch that is not in my hand? It fetches, reports and changes nothing — the released version and whether yours is free, the highest gate number on each side, and the list of source files the other session rewrote wholesale. Then take your version number, which should be the last thing you do and not the first.

That last list was wrong when it was first written, and the wrong version is the instructive one. It listed files both sides had changed since the merge base — the obvious heuristic. Checked against the merge that actually destroyed work, that list did not contain assets/site.css at all: the rules had landed two releases earlier, so they were already in the merge base and only their side had touched the file since. The heuristic could not have caught the thing it was named after. What catches it is churn — their rewrite changed 897 lines of a 411-line file, where the next busiest source file on the same range changed 31% of itself.

And the guidance page that holds it together, at [docs/guidance/concurrent-sessions.md]( ../docs/guidance/concurrent-sessions.md) and in the back office: what has actually collided, what each thing cost, which gate catches it now, and the ordering that avoids most of it — claim the work before you build it, take the version number last, resolve a generated file by rebuilding and never by hand, and when the other session rewrote a file wholesale, read your own lines in it afterwards. A clean merge is not evidence that your work survived.

Every one of the three gates was broken on purpose before it was trusted: a second file numbered from 34, the chat's column rules deleted again, a duplicated release entry, a note file that does not exist, and a version number already taken. All five go red; all five were restored.

admin/versions/v0.15.0.md — this note as markdown, which is what it is.

v0.14.02026-09-15

The chat's tools were fetching from the wrong place on every page but the front one. Reported by the editor with a screenshot: asking «what is the article I'm reading now?» came back with listar_artigos falhou: articles.json respondeu 404. The engine built the path to /api/v1/ by counting the segments of location.pathname, and it was wrong twice over in a way that hid itself — the count was short by one for a directory URL (/artigos/2026/09/14/<slug>/ needs five ../ and got four), and the result was then joined to a prefix that began with a slash, so the whole address collapsed to /artigos//api/v1/. On the front page raiz() returns nothing and the absolute prefix saved it, which is why it looked like it worked.

Counting was the mistake, not the arithmetic. assets/conversa.js sits at <root>/assets/conversa.js, so one step up from its own URL is the site root — at any depth, under any prefix, with no rule to keep in step with the page tree. It is the mechanism SgComponent already uses to find its own markup (static jsUrl = import.meta.url), and the reason a component can be moved without being told where it went. A 404 now also names the address actually requested, not just the path, because the old message sent you looking at a file that was there all along.

The second half of the same report: an id that was quietly repaired into a wrong one. The model called ler_artigo with 2026/09/14/dois-nomes-para-os-mesmos-palcos; the engine stripped the characters an id may not have and fetched 20260914dois-nomes-para-os-mesmos-palcos, a path nobody wrote. Silently repairing an argument turns a wrong question into a wrong answer — on this site the one failure mode that does not look broken. It now refuses, and says what an id is, which tool hands them out, and gives a real example. Each {id} tool carries its own example in the schema, and ler_artigo states outright that the folder is dated but the id is not.

A gate that asks questions. The chat's tools are only exercised when somebody asks something, and no gate asks anything — so the browser check now calls every listing tool for real, from the deepest page on this site and from the shallowest, and reads one article by an id the listing itself handed out. Both paths are needed: the article page is where the arithmetic broke, and the front page is where the old code accidentally worked, so a deep-only check would have passed a half-fix.

Gate 34's scope stopped one directory short of the code that mattered. It covered assets/components/ and missed the four scripts directly above them — the bridge, the observer, the graph viewer and the chat engine, about 240 comment lines, two of them about what leaves the reader's browser. All four are now in English and in scope, vendored third-party code excepted because rewriting it to our taste would break the reason it is vendored at all.

And the gates themselves now fail in English. Fifty-eight failure messages in build/gates_artigos.py were Portuguese while the summary line above them was English. A gate message in a terminal is read by whoever operates this newsroom, so it follows the same rule as the rest of the code; values quoted out of the files — a state, a section, a name — stay exactly as the file has them, because those are data.

Left for the migration release, recorded here rather than fixed quietly: the article state superseded is an English id in an otherwise Portuguese vocabulary. The label a reader sees is «Substituído»; only the id is out of place, and ids are what that release is about.

Also: admin/versions.json carried a hand-maintained contagem that drifted the first time two sessions each added a note. It is now counted from the list, and rewritten when it disagrees.

And a merge lost something quietly, which is worth recording because nothing failed. The design review's stylesheet arrived as a wholesale rewrite of assets/site.css; git auto-merged it without a conflict, and the four rules that give <pt-chat> its column went with it. The panel still opened and simply lay across the article again — the same symptom as the [hidden] bug two releases ago, from an entirely different cause. They are back, with a comment saying so.

admin/versions/v0.14.0.md — this note as markdown, which is what it is.

v0.13.02026-09-15

The design review's proposal, adopted. The editor asked for the vault's proposed design to be implemented, and this is it: assets/site.css is the proposal's stylesheet, and the generators now emit the vocabulary it introduced. One value is corrected, and the correction is the interesting part.

THE ONE NUMBER THAT CHANGED, AND WHY. The proposal sets --measure: 34em with the comment "~66 chars at 18px" and --measure-sm: 38em at "~68 chars". Both are wrong, and the second kind of wrong matters: it derives them from an average glyph width of 0.48em, the usual figure for a screen serif, where Newsreader — measured in Chromium on a canvas at the element's own computed font with real Portuguese text — averages 0.405em. About 18% narrower. Two consequences. The problem was worse than the review reported: the 758px column held ≈104 characters, not 88, and the 828px entity column ≈114, not 96. And 34em is 612px, which measures 84 characters — above the 75 the review's own §4.4 sets as the ceiling, and wider than the 29em live since v0.9.0. Adopting the proposal literally would have regressed the thing it exists to fix. The site stays at 29em, measured at 71 characters. The two tokens are the same value because an em measure is size-independent: the font size cancels in width ÷ (size × glyph), so 34em and 38em could not have given 66 and 68 in the first place.

THE BANDS, which are the structural half. One ground for a whole page is why the sections ran together: hairlines alone cannot separate blocks identical in tone and density. A newspaper varies the ground as well as the rule, and a reader navigates by that before reading a word. .band wraps a section and carries its own ground, with a .folha inside it so it can be full-bleed — which meant pagina() had to learn corpo_em_bandas, because a page that bands itself opens and closes its own sheets. The front page uses three grounds and not six: base for the lead, recessed for «Em preparação», base for the rest. A page that alternates every block is as flat as one that alternates none.

The section opener, at three weights. .sect alone is a label, not a door, and using it for both is the other half of why the page read as one field. .opener--major gets a 3px ink rule, a number and a title; .opener gets 1px; .sect stays the label for a block inside a section. This is deliberately not a blanket replacement for the 104 .rule + .sect pairs across the generators — the stylesheet's own note says to reach for the heaviest weight three or four times at most, and a page where everything is a door has no doors. The front page uses it once.

The provenance, demoted without being removed. The chips sat directly under the lead headline, where a newspaper puts the dek — three bordered 11px mono runs of character counts, turning a well-written lead into a debug panel. Now a sentence in the paper's own voice leads — "Assenta em 3 páginas congeladas hoje · 2 não devolveram texto" — and the chips open behind a <details>/<summary> disclosure. No JavaScript: it has to work with scripting off, and half this site's readers are machines that take the chips from the markup regardless. The sentence is derived from the same dict the chips are built from, so the two cannot disagree.

The stat strip, replacing a stack of label-plus-sentence rows where the counts — the thing an eye goes looking for — were buried mid-prose. And it immediately caught me repeating myself: the strip carried Oradores, Organizações, Fontes and Grafo, and the rows underneath said the same numbers again in sentences. That is «content exists once» broken by the session that keeps quoting it, and it made the rail run 406px longer than the column beside it. The strip keeps the numbers; the rows keep only what a number cannot say.

The column hole, and where it went. The review measured ~270px of blank paper at the foot of the left column while the rail ran on. «Nesta edição» is a summary of the whole edition rather than a third secondary story, so it moved under the lead: that filled the hole and took the same height off the rail. The imbalance is now 341px the other way — the lead column runs longer than the rail, which is what a broadsheet front page looks like. .g12 > .col { align-self: start } means the short column simply ends and its border stops with it, instead of a rule running down beside nothing.

Two more stacking bugs found by looking at the rendered page, not by reading the rules. The masthead band closed with 48px of its own and the lead band opened with another 48, stacking into ~120px of blank paper between the nav's double rule and the lead headline — on a front page, where the lead is supposed to be the first thing under the nav. The nav's 3px double rule is already the divider, so the first band's bottom padding goes to zero. And the lead grid still carried padding:34px 0 30px inline, a second helping of the room the band now provides — which is exactly the inline padding the review wants the scale to replace. Gap: 120px → 86px.

Measured after, in a browser: 71 characters per line, 5 bands, 3 full-ink .rule divisions (the stylesheet's target is ≤3), 1 major opener, and the mobile chrome above the masthead down from ~130px to 88px — the review's target is ≤48px and its own proposal reached 96px, so this is past the proposal and still short of the target, which needs the dateline abbreviated on small screens and is a generator change for another release.

Gate 29 learned to follow a token. The stylesheet names the measure — .std { max-width: var(--measure) } — which is right: the value lives in one place. But the gate looked for a literal em and so reported that .std had no measure at all while the measure was correct. A gate that cannot follow one level of indirection ends up forbidding the tidier code, which is the wrong way round.

What was NOT adopted, and it is the one thing the editor still owns. The proposal's front page de-duplicates the card grid, and this one does not. §4.5 decision 5 asks the editor to confirm that a front page showing three stories instead of six — more honest, visibly emptier — is acceptable, and says in as many words: "Agents: do not pick these yourself." The filter is written and switched off. The other four decisions are likewise still open, on the editor's board.

admin/versions/v0.13.0.md — this note as markdown, which is what it is.

v0.12.02026-09-15

The chrome stops moving when you cross between the paper and the back office, the spend gets a page of its own, and a layout bug this session shipped two releases ago is fixed. Three things the editor asked for, and one he found by looking.

The menu moved, and it moved a lot. The paper had a dateline, a countdown and a run of utility links, then a centred masthead and a section nav. The back office had a single row mixing its own identity, its own eleven links and the version — different order, different place. Clicking «Back office» rearranged every item in the chrome at once, which reads as arriving at a different site rather than at the back of the same one. The utility run is now generated once, in build/paginas.py, and both chromes emit it in the same slot with the same items in the same order. Measured in a browser: on both pages it sits 18px from the top and 56px from the right, 18px tall. Exactly one item differs, and it is the one that has to — on the paper it points into the back office, in the back office it points back out at the paper. The console's own links become a nav below it, wearing the paper's .nav with a single hairline instead of the broadsheet's double rule, because the back office is not the publication and should not claim the publication's furniture. It also now marks the page you are on, which the paper's nav always did and the back office never did.

A layout bug this session shipped in v0.9.0, and the editor caught it. Capping .std at 29em is right for the newspaper, where a paragraph sits inside a grid column about 758px wide. The back office has no grid: it is one column of prose in a 1328px sheet, so the same cap put the text in a 522px ribbon with 800px of empty paper beside it while every hairline went on spanning the full width. Text at a third of the page with rules across all of it does not read as a narrow measure — it reads as broken, and it was. The fix is the container, not the paragraph: the console's content column is 58em, so a rule is 1.78× the prose it divides instead of 2.5×. The first attempt at this fix put the width on .folha and quietly undid the chrome work above — the utility run ended up 312px from the window's right edge against 56px on the paper. Same slot relative to its own sheet, different place on screen. The narrowing is on the content and deliberately not on the sheet, and the stylesheet says so where somebody would be tempted to move it back.

The wallet stops being a popup. The editor asked for the budget spend on a page of its own, and the reason is not taste: the ledger is the interesting part of the demonstration, and a ledger worth reading is worth an address. A panel that covers the masthead cannot be linked to, cannot be read on a phone without covering what you were reading, and closes itself on the first click elsewhere. pt-wallet gains two modes on one template — a badge that is now a link in the chrome, and the ledger itself at /carteira/ with a stat strip, the actions and the last pages read. There is no second copy of the ledger: two copies would diverge the day somebody changed the price in one of them. The badge still debits the page it is on, because the debit is the demonstration and has to happen wherever the reader is. The wallet page carries both instances, and that is not a double debit — _charge() records the page in sessionStorage before returning, so the second one finds it already seen. The guard was written for the back button and happens to cover this; the comment says so rather than relying on it by accident. This also settles half of the design review's decision 4: the wallet is out of the reader's header as a panel, and whether the badge itself belongs there at all is still the editor's call.

The palette did not reach inside the components, and the design review found it. The five shipped components hardcoded #0f766e, #f7f4ec and most of the rest of the palette in their own CSS — so the v0.9.0 contrast fixes landed everywhere except inside them, which is the half of the site a reader spends longest looking at. Custom properties do cross the shadow boundary, because they inherit: a component reading var(--acento) picks up a palette change for free. It just needed a token to inherit, and there were none. 156 hardcoded colours across seven component stylesheets are now tokens, including eight near-misses — a paper that was not the paper, a hairline that was not the hairline, an ink that was not the ink — which is exactly the drift tokens exist to prevent. The one genuinely different colour, the purple that marks a JSON key, becomes --sintaxe-chave in :root: not because it was failing (7.93:1, comfortably above AA) but because a colour that is not in :root cannot be measured by the gate, and measured is the property that matters. Gate 33b now fails the build on a hardcoded colour in component CSS, with a var(--token, #fallback) still allowed — the token wins whenever it is defined, which here is always. Injection-tested: it catches an accent put back by hand.

Renumbered from v0.11.0, and the gate earned its keep on the way. The other session shipped its own v0.11.0 while this one was building — the third time today two sessions have reached for the same number — so this release moved up, because the one that has not shipped is the one that moves. Merging their work brought in a brand-new pt-chat component, and gate 33b failed the build on it immediately: it hardcoded #fff for the tab label and dot. There is no pure white in this palette; --papel is the house colour for light on ink, which is what .escuro already uses over --tinta, and on the accent it gives the same 6.83:1 the accent gives on paper. A gate written for one release catching the next release's component, on its first run, is the whole argument for writing it as a gate instead of a note.

And this release failed to deploy the first time, with all five gates green. Worth writing down, because the failure mode is the nastiest kind. validate passed, the five gates were green, the push to dev succeeded — and tag-release failed with "version.txt says v0.12.0 but the newest release commit in this history is v0.11.0". CI does not read version.txt alone: it reads the newest commit subject matching ^site vX.Y.Z:, because the subject is what names the commit to tag. This work went in under a merge subject — "integra o dev (v0.11.0) e renumera para v0.12.0" — which carries the number but not in the form CI parses. So nothing was tagged, deploy was skipped, and the live site stayed on the previous release while every local check said OK. A green build that does not deploy is the worst kind of green, and rewriting the subject was not an option: this repository does not force-push and does not rewrite history.

build/tudo.py now closes by running the same comparison CI runs and saying, in as many words, what the release commit's subject has to be. It is a warning and not a gate, deliberately: on a feature branch version.txt is legitimately ahead of the newest release subject for as long as the work is unfinished, and a gate that failed every intermediate build would teach whoever hit it to skip gates — which costs more than it saves.

admin/versions/v0.12.0.md — this note as markdown, which is what it is.

v0.11.02026-09-15

The chat stops being a card at the bottom of the page and becomes a column beside it. Modelled on VoiceDebrief: a vertical tab on the right edge, a panel that opens from it, and a drag handle to set the width. The distinction that mattered here is column, not overlay. An overlay covers the page you were reading, and on this site the page you were reading is the thing you are asking about — «em que fontes assenta isto» is not a question you can answer while the answer is behind the question. So <html> takes a padding-right equal to the panel and the article reflows beside it. The width, the open state, the materials you send, the model and your key all live in localStorage, so the panel opens the way you left it.

The engine was already here and was kept. assets/conversa.js had the whole apparatus — the level-0 comparator that runs in the browser with no key, the 18 GET tools, the OpenRouter call — and it was checked for before anything was written, because this repository has already produced two answers to one ask once this week. Only the presentation was replaced. What changed inside the engine is that it now emits each tool call as it happens and asks OpenRouter for usage, so the panel can show which file was opened, with what argument, how many bytes came back, and what the round cost — inline, where it happened, rather than behind a spinner. On a publication whose whole argument is that a claim walks back to bytes, hiding which bytes were read would be the wrong thing to hide.

Every tool is visible before you use one, and every one says LER. Not as a safety claim but as a consequence: the API of this site is files, so there is no write to offer. The panel also lists what travels with your question, as checkboxes, because a reader should never have to guess what left their browser.

The panel shipped broken, and this is how it was caught. .painel { display: flex } in the component's own stylesheet silently beats the browser's [hidden] { display: none } — one class selector against one attribute selector — so the panel that was supposed to be closed was lying across the article on all 227 reader pages. It passed every gate: the component reached pronto, nothing threw, nothing overflowed, nothing 404'd. It was found by taking a screenshot and looking at it. Gate: the browser check now reads the computed style of every element carrying hidden inside a component's shadow root, for every component, and fails when one is on screen anyway.

And the browser gate stopped sleeping. It waited 400 ms after networkidle and then asserted — which on /grafo/, where Cytoscape runs its layout on the main thread, found pt-chat with an empty shadow root and reported a broken component that was fine at three seconds. A fixed sleep measures the machine the gate runs on. It now waits for the data-estado the base class sets, up to ten seconds, and a timeout is still a failure — a component that needs longer than that is one the reader sees as an empty box.

Three deliberate injections confirmed the gate fails: a selector that is not in the template, a renamed .tpl, and the [hidden] rule taken back out. Sixteen pages, five gates, green.

admin/versions/v0.11.0.md — this note as markdown, which is what it is.

v0.10.02026-09-15

The language rule gets a test, a gate, and an apology. One question decides every naming question here: would a visitor read this string on the site? Yes means European Portuguese; no means English. CLAUDE.md has said "code and comments are in English" since the first commit, and seventeen Python files ignored it — including most of the ones written this week — because the code already there was Portuguese. An existing convention is not the rule. Roughly 880 comment lines across 22 files are now English, and gate 34 fails the build on the next one.

The gate was wrong twice before it was right, and both bugs are worth knowing. Its first version matched triple-quoted blocks with a regular expression and flagged eleven page templates in build/build.py — blocks of HTML holding the Portuguese a reader sees. It was telling the site to stop being Portuguese. Its second read ## markdown headings inside the llms.txt string as comments. Both are the same mistake: guessing at syntax a parser already knows. It now uses ast and tokenize, and skips «guillemets», because an English sentence naming a Portuguese section is still an English sentence.

Three files are exempt, by name, and the exemption is the editor's to clear. build/gates.py, build/entregas.py and build/pdf.py are in the deny list of .claude/settings.json, so no agent here can edit them — and a gate that fails the build over a file the agent is forbidden to touch is not a gate, it is a deadlock. They are named in the gate, counted in its summary, and listed here rather than quietly skipped.

Every agent gets a ROLE.md and a MANDATE.md — rendered, not written. And the story of how is the useful part: two sessions answered this same ask on the same afternoon. One wrote five mandate files by hand; the other built dados/agentes.json, which already held every field those files would state. Two answers to one ask is two copies, and two copies diverge the day somebody edits one. The register won — it shipped first and is wired into the API, the console and the mail — and the hand-written files were deleted. build/mandatos.py now renders agents/<id>/ROLE.md and MANDATE.md from it, marked DERIVED FILE, with English scaffolding and the register's values quoted verbatim in Portuguese. Gate 35 fails a run naming an unregistered agent, a registered agent with no mandate file, a mandate not marked as derived, and — the one worth having — a mandate whose domain, mission or central claim has drifted from the register. A mandate that no longer matches reads as authoritative while being wrong.

Guidance, at docs/guidance/ and in the back office. Four pages, in the order they should be read: the language rule, the nine principles each naming the gate that enforces it, the before-you-change checklist, and what not to build. Modelled on sgit.ai/docs/guidance, and on the lesson from coding.sgit.ai that measured its own estate and found a guard that had never matched anything: a rule without a gate is decoration, so each principle names its gate or says it has none.

Evidence from a sibling publication, kept as its evidence. Fourteen frozen files from newsroom.sgit.ai — two Startup Summit captures from 8 and 13 September, and three press pages — verified against their manifest and stored under fontes/transferidas/, never in our own register. They were frozen by another publication's fetcher, at its times; entering them as our captures would make this site's method quietly untrue, which is the one failure mode that does not look broken. They turn a story explicitly waiting for a second capture into three captures in six days: 60 → 64 → 70 speakers.

admin/versions/v0.10.0.md — this note as markdown, which is what it is.

v0.9.02026-09-15

Released by @Bastidores. Translated into English here, like every other note, under the language rule adopted in v0.10.0 — the words are that release's, the language is the house's.

The design review, implemented — and what it measured becomes a gate. The editor commissioned a design and legibility review, with the complaint that "the current design is hard to read, and does not look as professional and smooth as a news site or a strong blog should". The review arrived in an encrypted vault (e54hfntq, v0.1.0, against build v0.6.0) and did the unusual thing for a design review: it measured. Characters per line derived from the grid rather than eyeballed, contrasts computed from the tokens in :root, empty cells counted one by one, screenshots at 1440px and 390px. That is why this release is large and specific rather than a matter of taste.

"Hard to read" had four measured causes, and they compounded each other. The text column ran 88 characters per line on the front page and 96 on an entity page, where a newspaper column sits at 45–75 — above that the eye loses the return to the next line, and the experience is exactly "I can read it but it is tiring". Leading was at 1.45, where a serif on cream paper wants 1.6–1.65. -webkit-font-smoothing: antialiased was drawing the text lighter than specified, taking weight off the strokes Newsreader needs at 15–18px. And the rules were not visible: --filete sat at 1.36:1 against the paper, when the standard asks 3:1 of a non-text element carrying meaning. This stylesheet's entire thesis is "rules, not boxes" — structure is carried by rules — and the structure was being carried by lines that, on a light screen, are not there. That is why the front page's blocks appeared to run into one another.

"Does not look like a news site" had one dominant cause, and it cost ten lines. IBM Plex Mono set the dateline, every kicker, every section label, the front page's thirty chips, every table header and the entire footer. Cumulatively the page read as a build log with a good headline. A newspaper does not set its section labels in a typewriter face. The kicker and the section label move to the serif, and with them the table header and the prose of the "for an agent" block. The rule now written into the stylesheet: the mono face means one thing — a machine fact. A hash, a byte count, an identifier, a time, a path, code.

The accent had seven jobs and therefore signalled none. Kicker, current section, link-on-hover, confirmed source, ok-chip, graph verb and "open the graph". It now has two: interactive, and confirmed. The kicker leaves it. And three of the four state colours failed or scraped AA — --disputa was at 4.48:1, below the line, carrying verification state at 11px. In a publication whose whole proposition is that a reader can see the verification state of every claim, that is not a contrast detail. The ramp darkened: accent at 6.83:1, warning at 6.06:1, disputed at 5.81:1.

Seven gates, 27 to 33, so it is not reviewed by eye again. The review's §4.4 said the thing that stops a review becoming decoration: "Do not review by eye again. These are the numbers that changed. Each is cheap to assert in a build check." build/gates_desenho.py is that file — each gate reads a value off disk and compares it with a threshold the review wrote. Two of the seven numbers need a browser and live in the browser gate instead, which is said rather than omitted.

And one number in the review was wrong, in the direction that mattered. The review used 0.48em as Newsreader's average glyph width, the usual figure for a screen serif. Measured in the browser — a canvas, the element's own computed font, real Portuguese text — it is 0.405em, about 18% narrower. That cuts both ways: the problem was worse than reported (the 758px column held ≈104 characters, not 88), and the fix the review asked for did not reach the target it set (34em is 612px, which measures 84 characters, still above 75). So the site's measure is 29em, not 34em, and the gate measures with 0.405. The way to get the number again is the same as the way it was got: measure, do not estimate.

admin/versions/v0.9.0.md — this note as markdown, which is what it is.

v0.8.02026-09-15

Released by @Bastidores. Translated into English here, like every other row, under the language rule this site adopted in v0.9.0 — the words are that release's, the language is the house's.

The agents become a register, and the mail between them becomes a protocol. It was three paragraphs in dados/equipa.json and two loose messages in a folder. dados/agentes.json becomes the register, in the form teams.sgit.ai publishes for a ROLE.md crossed with the one sgraph.ai publishes for each agent on its team. Five roles, and the fifth — @Bastidores — writes not one line of the paper. Five others are named as not built, with the reason, because a register claiming roles nobody runs would be claiming a capability.

The field that makes this a team is "not responsible for". Without it every role silently becomes the same role — which is why it is in every definition, saying whose the work actually is. Each role's central claim is written as a failure condition, so it can be contradicted rather than admired.

Email-FS-lite, as published, with the difference said out loud. The mail runs on the protocol the sgraph.ai team publishes and runs: one writer per folder, dispatch as the only shared surface, one commit per cycle, immutable .eml messages that only move. A message's state is the folder it is in and not a field, because a field can disagree with the folder and a folder cannot disagree with itself. The protocol was designed for a vault, where sgit pull is the inbox notification; here it runs in a git repository, and that difference is written in redacao/correio/LEIA-ME.md rather than disguised.

One reader, and a second reader caught out of date. build/equipa.py reads the folder and derives dados/correio.json; everything else reads the derived file. v0.3.1 was the deletion of a second document reader, and this cycle's integration showed why: build/mesa.py counted the mail by opening the folder, and after the migration counted zero without failing. A fault that does not fail is the worst kind, and two readers created it. There were also two lists of agents — one hand-written in build/comentarios.py — and now there is one.

The bridge: the editor can talk to the newsroom from the browser. This site is static and cannot write to the repository; a vault append queue closes that gap without giving it a server, because it splits access into four capabilities that do not overlap — whoever holds the code appends and nothing else, and the response to an append is blind on purpose. It is built and credential-free: the vault id and the code are given to the browser and live in localStorage, because the validator's key-shaped-string tripwire would fail the build if one entered a file — which is the gate working rather than a rule being remembered.

A finding from the games vault that changes the design. Its telemetry built events and never sent them. The cause was not the code: a vault app runs in a frame whose CSP is connect-src blob: data:, and the only way out would reopen every exit from a frame holding decrypted content. This site is not a vault app — so what the games telemetry could not do, this site can. The difference is one of surface, not of effort.

"Talk to this content", at both levels, and level 0 is the default because it says why it chose. A deterministic comparator running in the browser against the index the build emitted, showing the words that matched and where. Level 1 is the reader's own key, with eighteen tools that are GETs to /api/v1/ — unusually safe to hand a model, because the API is only files and the worst a call can do is read something already public. Four new API collections: agents, mail, board and bridges.

What is blocked, and is the editor's. dados/aviso.json does not mention telemetry, and while the bridge is off that is correct. The moment it takes credentials the notice is wrong by omission — and the IP address the queue's server sees has to be written down, because it is the one part the reader cannot verify. The order is the notice first, the credentials after, which is the order this site already got right once, when the notice was published before the first page that names anybody.

The review of the other agent's work, and three things it changed. The browser gate he added the same night — admin/build/render.mjs, which opens every page in a real Chromium — found a real defect in this version the first time it ran over it: the bridges page overflowed at 390px, scrollWidth 413 > 390. The cause was a <select>, which takes its width from its longest option and will not shrink below it. Fixed, and it is the gate's argument made in public: none of the other three would have seen it.

The gate did not cover the new pages, and the omission was exactly the exemption gate 26 forbids. render.mjs's page list is made of components, and this version's four pages use none — but they have code that runs, and the bridges page has the largest script block on the site. All four join the list: 15 pages in a browser instead of 11.

And "ready" on two components did not mean what the base class says it means. The comment on pronto() promises data-estado is only set once the component "has actually finished loading whatever it loads". pt-json-viewer and pt-doc-browser started their load without awaiting it, and so became "ready" when the shell mounted — the same false health falhou() was added to remove, one level down. Both now await what they started.

admin/versions/v0.8.0.md — this note as markdown, which is what it is.

v0.7.12026-09-15

The skill a scheduled run follows was telling it to do impossible things. .claude/skills/newsroom-run/SKILL.md said git pull origin main and git push origin main — and this repository has no main; the release branch, which is also the default branch, is dev. And it said to run node build/validate.js, which does not exist: the validator is in admin/build/. A scheduled run would have died on the first line of the first step.

And the record it told the run to write was not the one the gates read. The field names were all wrong — inicio, issues_movidas, ficheiros_por_pasta, portas — where gates 12 and 26 read quando, issues_movidos, pastas_alteradas and portoes. The run would have produced a record the department boundary cannot check, and since gate 26 it would fail outright for declaring neither a department nor a species.

And the build sequence had six steps where there are now eleven. It becomes python3 build/tudo.py, which is the real order and the only one that cannot go stale without something failing. The publish step now fetches dev before choosing a version number, because another session may be working here at the same time, and two picking the same number means one has to undo its release.

admin/versions/v0.7.1.md — this note as markdown, which is what it is.

v0.7.02026-09-15

The fourth gate: open the pages in a real browser. Three gates read files and read HTML. Neither executes anything. A component that throws on load — a wrong path, a fetch for a renamed file, a syntax error in a module — passes all three and reaches the reader as an empty box. And an empty box looks like a design choice. admin/build/render.mjs opens every page carrying a component in a real Chromium and fails on a console error, a failed request, an empty component, or a page overflowing at 390px.

And its first injection test found a hole in the gate itself. A component that catches its own error and draws a friendly message is doing right by the reader and wrong by whatever checks the page: no console error, no exception, a shadow root full of text — it looks healthy. An exception mid-_render() got through. The fix was not in the test: the base class gained falhou() and data-estado on the host, outside the shadow root, and every component now says whether it came up. Six injection tests, including one that never finishes loading.

The distinction that was kept. A request the READER made and that failed does not mark the component as in error — in an API console, watching a request fail is half the point. falhou() means "this component did not come up", and widening it would erase exactly the distinction that makes it useful.

A favicon, and one fewer 404 on every page. Chromium asks for /favicon.ico unprompted and no gate saw that 404, because it is nobody's link — the browser gate found it. The site's paper, ink and accent, and nothing else: a paper that has not published a single story has no logo to put there.

Edge targets start looking like what they are. On an entity page the value of the page is being able to follow the sentences — and the house style only underlines on hover, which makes a link look like text. A class of its own, .no rather than .ent, because gate 23 counts .ent links — one per entity per page — and a list of sentences repeats the same node many times, deliberately. The same thing to a reader, different things to a gate.

admin/versions/v0.7.0.md — this note as markdown, which is what it is.

v0.6.02026-09-15

The desk becomes a room. /redacao/ opens with four benches — Research, Newsroom, Verification, the editor of record — each showing how many cards are waiting on it and how much of its work is still open. Click one and the room answers: what it does, what it refuses to do, which folders it writes in. A table shows rows; a room shows load, and load is the question you ask a newsroom at five in the afternoon.

There is no way to move a card, and the absence is the design. A story's state lives in the files of its own folder, and whoever changes it is whoever has write access there. A room that let you drag a card into «publicado» would be handing out, in one click and to anyone who opened the page, the exact gesture this publication reserves for a named human. The plain table stays below, JavaScript-free: half this site's readers are machines, and a newsroom only one of them could read would be the wrong irony.

A defect caught in the building of it. The first version of the board assumed one article per issue and hid an article — issue 001 commissioned two stories, and the board showed six cards and looked complete. A board that hides work is worse than no board, precisely because it looks complete. The card becomes the ARTICLE, which is the thing with files.

Gate 26: "construction" is not the word you write to escape the boundary. Gate 12 exempts a run that declares no department, because the bootstrap session creates everything. This week's sessions are construction and use that exemption — so they now have to earn it: freeze no source, move no card, publish nothing, record no version on red gates. Five injection tests. An exemption any run can claim by typing the right word is not a boundary — it is a door with the name written beside it.

admin/versions/v0.6.0.md — this note as markdown, which is what it is.

v0.5.02026-09-14

The agents' work goes on display — and none of it was written. Every article gains a comentarios.json and a map below its provenance: who said what, when, and what was left open. 62 entries, 5 agents, 35 still open.

The easy way to do this would have been to write the comments. They would read well — one from ChatGPT suggesting, one from Perplexity disagreeing — and none of it would have happened. Attributing to another provider's model a sentence it never wrote is worse than a claim with no source: it is a claim with a false source. So every entry is DERIVED from a record that already exists — the provenance timeline, the verification record, what the newsroom says it still lacks, what arrived from outside with the result of searching the frozen bytes for its excerpt, and the editor's decision or its absence — and names in its de field the file and path it came from.

Gate 25 opens that file and walks that path. If it does not resolve, the build fails: the claims rule turned inward. Seven injection tests, and the last is the one worth keeping — an agent name nobody declared is an anonymous contributor to a publication whose entire argument is knowing who said what.

An outside proposal attaches to an article by SECTION, and says so in full. "It arrived for the policies section, which is this article's section" — not a claim that it is about it. That would take somebody reading both, and that somebody is the editor.

build/tudo.py: the whole build in one command. Eleven steps and the order matters — entidades.py must run before anything that writes a page, because its output is what turns a mention into a link. Out of order nothing breaks; the site just quietly has fewer links. The command list in CLAUDE.md is older than half these steps, and that file is the rules file — deny-listed on purpose, and editing it to match the code is backwards. The real order now lives in an executable file, the one place it cannot go stale without something failing.

admin/versions/v0.5.0.md — this note as markdown, which is what it is.

v0.4.02026-09-14

Names get addresses. 198 entities — people, organisations, institutions, publishers, topics, places — each with a page at entidades/<type>/<id>/, and the first mention of each one on any page of the paper now links there. 313 links. An entity page has not one line written about it: it has the source's verbatim fields, the edges read aloud through the reading published in the ontology — «o evento lista X sob Y» — and a count of which frozen file the name appears in and how often. This is §6 paying for itself: it is what made refusing relacionado_com worth it.

The linking formula is published, because it is a classification. Verbatim name with the accents the bytes gave it, at least 6 characters, word boundaries, the first mention on a page and no other, never inside another link. And only types that name a thing of their own: linking the word "hardware" to a Technology page would be saying that word is a reference to that node, and it is not — it is the word. A link like this says "the text contains this name", never "the text is about this entity".

The graph starts knowing WHOSE each byte was. Every frozen source gains a publicada_por edge to the house that publishes it, derived from the register by the editor_de_fonte formula. A new type, Editor, rather than widening Instituição: that type's published definition is "a public body, a regulator or a research unit", and a newspaper is none of those — widening a definition people have already read changes what it promises. And two names still different after the formula stay two nodes: if the register says «Diário da República» and «Diário da República Eletrónico», those are two things it says.

Two grounds for a link, and the page says which. The strong one is the name being in the bytes (179 entities). The weak one is being the name our own register gives a source's publisher (4) — it exists because «Diário da República» publishes two frozen sources and appears in no byte at all, since that register's home page returns 22 visible characters to an automatic reader, which is one of this paper's own stories. The two are not conflated: the field says which, the page explains it, and gate 22 fails the build if anyone invokes the weak one while holding the strong one.

Four new gates (21 to 24), ten injection tests. Two of the tests caught defects in the gates themselves before catching anything in the site, and gate 24 — "no Pessoa page says anything about the person" — had to be hardened when the organisation cell gained a link: a gate that stops seeing half of what it guards guards nothing.

admin/versions/v0.4.0.md — this note as markdown, which is what it is.

v0.3.12026-09-14

There were two document readers, and a site that says content exists once cannot have two. backoffice/viewer.html was written before the request to put the list on the left and the document on the right; once that was done in <pt-doc-browser>, there were two implementations of one thing — and two implementations diverge, always. No page linked to the old one, but deleting an address that once existed breaks whoever kept it: it becomes a permalink that forwards to docs.html, carrying the #fragment with it, so the requested document still opens. There is one reader.

admin/versions/v0.3.1.md — this note as markdown, which is what it is.

v0.3.02026-09-14

The title becomes the subject. «O Ecossistema Português de IA» is the masthead; the address becomes the subtitle. A reader arriving does not care about the domain — they want to know what it is about. And the front-page headlines become LINKS to their articles: a newspaper whose headline links to nothing is a poster, and that is what this was.

A read-only API, in English, with a console. /api/ — 18 collections, 28 operations, 377 files. Every path is a file on disk: there is no server, so there is no verb but GET, no request body anywhere, and nothing that can fail in a way openapi.json does not predict. The paths are English on purpose — the intent is several languages over one set of data — and the KEYS inside the documents stay Portuguese, because they are the record this publication keeps of itself and translating them on the way out would serve something no file in the repository contains. The console makes the request from the browser and shows the bytes: a console that only describes is a document with extra steps.

Web components, to the estate's contract. Four, in the form coding.sgit.ai documents: native, no framework, no build step, static jsUrl = import.meta.url so they locate themselves, onReady() rather than connectedCallback, and the versioned path v1/v1.0/v1.0.0/. Two departures, both forced by this site's own rules: the base class is hosted here rather than coming from a third-party CDN, because a page that needs a third party to render is a page a third party can stop rendering; and the triplet's markup file is .tpl and not .html, because here every .html is a PAGE to the site gate — it must carry a canonical, an agent block and a sitemap line — and a component fragment is none of those. The alternative was weakening the gate, and the gate is deny-listed on purpose.

Documents become readable without losing your place. Tree on the left with folders that collapse — briefs/pack/ is one element, not thirty rows — and the document on the right, on white, like a document. References to the rest of the estate sit at the foot of the tree. Before, it was a list that threw the reader onto another page and took the others away.

An article's files open in a viewer. Clicking afirmacoes.json stops returning a raw file: states get colour, a source id links into the register, a SHA-256 is shortened. The raw file stays one click away.

Paying to read, demonstrated rather than argued. One cent a page, five euros in the wallet, a top-up at zero, and the ledger in view. It charges nobody anything and says so in the panel's first sentence — a paywall that would not admit to being a demonstration would be the one dishonest thing on a site whose entire argument is provenance. And it never blocks: the interesting part is the ledger, not the lock.

The declaration stops being a block on every page and becomes one line with a page behind it. Repeated thirty times, a notice stops being read — the reader learns the shape of the block and skips it, which is the opposite of what a notice is for. /proveniencia/ owns what this is: written by AI agents, from several providers, with light curation by a named human editor. Saying "light" is more honest than leaving a line-by-line review implied.

admin/versions/v0.3.0.md — this note as markdown, which is what it is.

v0.2.02026-09-14

An article becomes a dated folder. artigos/<yyyy>/<mm>/<dd>/<slug>/ with four files: the prose, the verification record for each claim, the provenance — which run by which agent produced it, in what order — and the JSON saying what the article is. A folder and not a file because an article is not only the text: it is the text plus the bytes it stands on plus whoever re-read them plus whoever wrote it, and in a single file three of those four end up elsewhere and stop agreeing.

The date in the path is the date of the material, not of publication. Six folders opened: three with prose and verification done, three gathering material for the stories §11 of the brief commissioned. None published — that line is the editor of record's. dados/historias.json becomes DERIVED from the folders, as the organisations are derived from the speaker cards.

Each of the eight sections gains a folder and an editorial record. seccoes/<id>/seccao.json says what the section covers, what it can and what it CANNOT claim today, which sources it has frozen and which did not resolve, and which questions are open. The two sections with not one frozen source are forced by gate 20 to answer «Nada.» to the question of what they can claim.

A back office, in English. /backoffice/: the operations console with the pipeline's state, who may write where, the board, the articles, each section's coverage, the runs and the commits — and a reader for this repository's 32 markdown documents, reading them from their own bytes rather than a copy. It is in English by the editor's decision, and the condition of that exception is checked: gate 19 fails the build if a back-office page cites a frozen source as evidence. The console reports the newsroom, never the world.

Five new gates, in a separate file. build/gates_artigos.py exists apart because build/gates.py is in the deny list of .claude/settings.json: an agent that can edit the gate that stops it has no gate at all, and the wrong way to add checks would be to lift that protection. All five were injection-tested, like the others.

Two rendering defects fixed, and both are worth naming: the markdown renderer made every LINE a paragraph, which broke prose mid-sentence and left a **bold** crossing the break without closing; and article prose had no vertical rhythm at all because .std has zero margin — right on the front page, where the spacing comes from the column's gap, and wrong in an article, which has no column.

admin/versions/v0.2.0.md — this note as markdown, which is what it is.

v0.1.22026-09-14

The loop closes in view. The deliveries page starts showing where a delivery comes from — the two research briefs an outside assistant receives — beside what happened when it came back. They are linked from the repository and not republished as pages, by the same principle that governs the frozen copies: this site links, it does not reproduce, and content exists once.

There are two briefs and not one because the tools fail in different ways, and the page says so: one cites well by default and searches well in Portuguese; the other structures better and is weaker at guaranteeing it actually opened each address — which is precisely the weakness the excerpt gate catches.

admin/versions/v0.1.2.md — this note as markdown, which is what it is.

v0.1.12026-09-14

The approval step stops being folklore. Each delivery's page starts showing exactly how the editor of record approves or rejects an item — with that delivery's REAL identifiers, not an example with invented names the editor would have to translate before using, which is where identifiers get mistyped. The item suggested for approval is the one with the most claims whose excerpt is in the bytes; the one suggested for rejection has none.

A new skill: /newsroom-entregas. The review session for a delivery, written for the editor: receive the file without touching it, freeze what it names, check every excerpt, and decide item by item. It also says what to do when a source is not readable but should be — the Diário da República PDF route — and why a 403 is not worked around.

A rule that was in the gate and written nowhere the editor would read it: you do not approve an item whose claims are all without a readable source. Approving means "this may be written as fact", and in that case there are no bytes to hold it up. The route is to open a research issue, not to lower the bar.

admin/versions/v0.1.1.md — this note as markdown, which is what it is.

v0.1.02026-09-14

The MVP. The ingestion path running against real sources (fetch, freeze, hash, extract, diff), the graph with Portuguese verbs, the data-protection notice, the front page as the design fixes it, the newsroom desk, and the review flow for research deliveries.

Three decisions recorded rather than left implicit. (1) The release branch is dev, which is also this repository's default branch — the pack requires the release branch to BE the default branch, because a scheduled run clones the default branch, and it called that branch main only because it was written before this repository existed. (2) There is one stylesheet, and the front page uses it like every other page: the pack allows two, and two would diverge. (3) Graph readings that touch a person are gender-invariant, because this site does not know anyone's gender and inferring it from a name would be an inference about a named person.

What this version is NOT. No story published — the first three are issues in «procurado», and publishing is the editor of record's line. Three of the eight sections with not one node, and saying so on their own pages. No agent level beyond A tested. Nothing from any research delivery appears on a reading page: gate 13 fails the build if it does.

One capability worth building. The Diário da República renders by script and returns 22 visible characters to an automatic reader — but it publishes its instruments as PDFs, encrypted with the standard security handler and with subsetted fonts, which makes a generic library return zero characters in silence. build/pdf.py decrypts them and reads each font's /ToUnicode map. Without it, this newsroom would have published "the national register cannot be read" when the truth was "we had not implemented the decryptor".

admin/versions/v0.1.0.md — this note as markdown, which is what it is.

For an agent. The release history is data: versions.json is the index and each note is a markdown file beside it. This page is rendered from them by build/versoes.py, and admin/build/validate.js checks the table has a row for the version in admin/build/version.txt, with no duplicates. Version v0.23.15.