Skip to content

Documentation log

One entry per triage round (ADR 0010). This records the judgement, not the writing: the commit range that was looked at, and one line per page with the verdict and the reason.

The verdicts are created, updated and no-change. The no-change line is the one that matters most: it says a page was reported stale, somebody read the change, and the text still holds — which is what keeps the next round from re-reading the same range. A triage round that only ever reports updated is rewriting instead of maintaining.

docs/_hooks/freshness.py reads the last range as a floor: everything up to its end is closed, so only what landed after it is reported. The format is deliberately rigid, and testing/unit/docs/test_docs_frontmatter.py pins it: the range is a - **Range:**start..end` line with short or full shas, and part of a## — ` entry.</p> <h2 id="2026-10-08-the-documentation-base">2026-10-08 — the documentation base<a class="headerlink" href="#2026-10-08-the-documentation-base" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>2b10f14..4681067</code></li> <li><strong>Pages:</strong></li> <li><code>index.md</code> — created (the front door of the site)</li> <li><code>guides/install.md</code> — created</li> <li><code>guides/operate.md</code> — created</li> <li><code>guides/curate.md</code> — created</li> <li><code>guides/data-model.md</code> — created</li> <li><code>adr/0010-documentation-coverage-and-freshness.md</code> — created (the decision this log serves)</li> <li><strong>Divergences the writing surfaced, and the verdict on each</strong> (every one read in the code before the edit; the code fixes are separate commits, not documentation edits):</li> <li><code>.env.example</code> carried the reference collection's <code>DB_NAME</code> — <strong>fixed</strong> (a fresh clone defaulted to somebody else's catalogue name), and <code>guides/install.md</code> was updated in the same change, which is the loop this log exists to record.</li> <li><code>AGENTS.md</code> listed the environment variables without the 14 <code>AUTH_*</code> and without <code>WORKER_RUNTIME_MAX_WORKERS</code> — <strong>fixed</strong>; the table in the install guide is now the complete one and the coverage gate keeps it that way.</li> <li>The screen count was wrong in three places (23 menu entries, 24 routes, "25" in the draft of ADR 0010) — <strong>fixed</strong> to the measured 24, with the menu's 23 explained on the page.</li> <li><code>runner.py</code>'s comment put <code>macro-category</code> last, against its own <code>PIPELINE_ORDER</code>; the <code>README</code> claimed every worker stamps the document, which is false for <code>thumbnail</code> and <code>conflict-judge</code> — <strong>both fixed</strong>.</li> <li>Left open as <strong>code</strong>, not text, and documented as behaviour on the page: the cleaning rules have no reactivation route although the screen says deactivating is reversible; <code>POST /taxonomy/tags/suggest-macro</code> writes nothing and declares <code>CURATE</code>; and the inbox's <code>IMPLEMENTED</code> set does not list <code>/entidades/conflitos</code> or <code>/qualidade/anomalias</code>, which exist.</li> </ul> <h2 id="2026-10-08-the-cpu-build-of-torch">2026-10-08 — the CPU build of torch<a class="headerlink" href="#2026-10-08-the-cpu-build-of-torch" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>4681067..b3db7e2</code></li> <li><strong>Pages:</strong></li> <li><code>guides/install.md</code> — <strong>updated</strong>: the lockfile no longer carries the CUDA runtime. <code>torch</code> comes from the CPU-only index now (measured: 3.46 GB → 0.66 GB, and 2.19 GB of the old lock were <code>nvidia-*</code> packages), so the paragraph that justified <code>CUDA_VISIBLE_DEVICES=""</code> by the wheel was no longer true: the variable covers the installation that re-locks for a GPU, and the routes to CUDA that do not pass through torch. The "To use a GPU instead" list gained the re-lock as its first step, because without it the GPU presets have no wheel to run on.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same section, same structure.</li> <li><strong>One front-matter decision in this change:</strong> <code>pyproject.toml</code> joined the page's <code>sources:</code>. It is where the index decision lives, and it was the one install-time fact the page did not track — which is why the report had not been able to see the change coming. It is also why this entry's range reaches back to <code>4681067</code>: the page was already stale against the dependency bumps the previous round closed, and adding the source is what made it visible.</li> </ul> <h2 id="2026-10-08-the-suites-own-cost">2026-10-08 — the suite's own cost<a class="headerlink" href="#2026-10-08-the-suites-own-cost" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>b3db7e2..e8a7a61</code></li> <li><strong>Pages:</strong></li> <li><code>guides/data-model.md</code> — <strong>no-change</strong>: the only thing the page takes from <code>testing/conftest.py</code> is <code>archive_error_fingerprint</code>, mirrored into the test schema, and the story of a schema built by <code>create_all</code> instead of Alembic. Neither moved. What moved is test infrastructure — the Litestar application built once per session instead of once per test, and the argon2id cost lowered for the suite — and the page does not describe either.</li> <li><strong>A gap worth knowing, not a verdict:</strong> <code>guides/install.md</code> and its translation also mention <code>testing/conftest.py</code> (the sentence about the test schema not being built by Alembic), but they do not declare it in <code>sources:</code>, so the report does not track it. Leaving it out is defensible — the mention is incidental to an install guide — but it means that sentence can go stale in silence, and this is the note that says so rather than a claim that the page is current.</li> </ul> <h2 id="2026-10-09-the-roadmap-leaves-the-repository">2026-10-09 — the roadmap leaves the repository<a class="headerlink" href="#2026-10-09-the-roadmap-leaves-the-repository" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>becba5e..cb85181</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: two sections appended, <em>Measuring the collection</em> (the query, and the one stamp that does not name its producer) and <em>Known limits and accepted trade-offs</em>.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the translation of both sections, with the Portuguese anchors the build actually emits.</li> <li><code>index.md</code> and <code>index.pt.md</code> — <strong>updated</strong>: the pointer to the retired page becomes the open issues and their milestones, and the sentence about the current state now names the limits page.</li> <li><code>adr/0009-authentication-and-authorization.md</code> — <strong>updated</strong>: the gap it recorded in the retired page now points at the limits section. An ADR is a page like any other for freshness, and this was the only reference to a <em>current</em> fact rather than to history.</li> <li><strong>The pages that did not need work, and that is the finding:</strong> the retirement was expected to move a safety list and a set of failure modes. Both were already documented under the gate — <code>guides/curate.md</code> owns "what has no way back" and the arrangement undo, <code>guides/operate.md</code> owns the reprocessing window and the single-process assumption, <code>models/taxonomy.py</code> owns the classifier's baseline, and <code>AGENTS.md</code> owns the failure modes. So the move was the two new sections above, and the rest was deleted as duplication rather than relocated.</li> <li><strong>Not carried over, on purpose:</strong> the retrieval measurements (MRR and Hit@10) and the collection's own state. A page that stores a number which is expected to move is a page that lies on the next run, and those belong to the open work that measures them.</li> <li><strong>A defect the change exposed, fixed in it:</strong> the in-site link check resolved an anchor against the canonical page even when the linking page was a translation — the opposite of what its docstring states and of what the build produces. Measured on the built site, the Portuguese page keeps the Portuguese anchor. The check now bites in both directions; before the fix it accepted a link that would answer 404 in the browser.</li> </ul> <h2 id="2026-10-09-the-menu-collapses-to-an-icon-rail">2026-10-09 — the menu collapses to an icon rail<a class="headerlink" href="#2026-10-09-the-menu-collapses-to-an-icon-rail" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>cb85181..0b41007</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: a new <em>The menu collapses to icons</em> subsection, plus a clause in the introduction pointing at it. It records what the shell now does — the control in the menu's own header, the 64px icon column, the choice remembered by the browser in <code>localStorage</code> (presentation state, not an account setting), the label kept as the link's accessible name with the hint carried by the <code>title</code>, the active entry keeping the accent colour, the section headings becoming hairlines — and that the wordmark leaving the menu does not touch the attribution, which <code>AttributionFooter</code> renders at the foot of every screen (ADR 0006). Every claim was read in <code>AppShell.tsx</code> and looked at in the SPA in both states, reload included.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation of the clause and of the subsection.</li> <li><strong>The sentences that did not move:</strong> what the page already said about the menu is still true — twenty-three entries, one route that is not an entry, the permission filter that hides a group and never grants, and the mirror that cannot grant. Collapsing is presentation and does not touch any of it.</li> <li><strong>Not documented, on purpose:</strong> the twenty-three glyph choices. The code names one per entry and a page that listed them would go stale the first time somebody picks a clearer one; what the page owes the reader is where the icons come from and what the collapsed entry keeps.</li> </ul> <h2 id="2026-10-09-the-setup-screens-move-behind-a-configuracoes-landing">2026-10-09 — the setup screens move behind a Configurações landing<a class="headerlink" href="#2026-10-09-the-setup-screens-move-behind-a-configuracoes-landing" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>0b41007..bec40af</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the introduction (23 menu entries → 16, 24 screens → 25, and what the cards are), <em>The menu hides; it never grants</em> (the card rules: hidden by <code>can()</code>, a tab with nothing visible not rendered, the entry present while at least one card is reachable, and the direct URL that explains itself), a new <em>Configurações</em> screen section with its three tabs and eight cards, and one pointer in each section whose group left the menu — Arranjo (the plan is a card; the diagnostic moved into "Acervo"), Catálogos and Sistema. The per-screen sections stayed where they were: what they document did not change.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation of all of the above.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: one sentence, and it is why this page was reported at all — it called the operations panel "the <code>Sistema</code> section of the SPA", and that section no longer exists. It now says the three cards of <code>/configuracoes</code>, under <em>Operação</em>.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same sentence, plus the section heading, which carried a "(Sistema)" in parentheses.</li> <li><strong>A finding the report produced:</strong> <code>guides/operate.md</code> went stale for a reason that has nothing to do with the workers it documents — it declares <code>apps/curator/src/router.tsx</code> among its sources, which is what let the report catch a navigation fact stated in the operations guide. The declaration earned its keep, and it is the argument for keeping the incidental sources rather than trimming them.</li> <li><strong>Deliberately not documented as done:</strong> the worker settings split, the issue's second bullet. The panel still mixes configuration and the machine view; that work is issue #53, and the pages describe the screen as it is today.</li> </ul> <h2 id="2026-10-09-the-first-administrator-and-the-window-the-lock-cannot-close">2026-10-09 — the first administrator, and the window the lock cannot close<a class="headerlink" href="#2026-10-09-the-first-administrator-and-the-window-the-lock-cannot-close" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>bec40af..8dde39f</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0011-first-run-setup-without-an-open-door.md</code> — created. It amends ADR 0009 §4, §6 and the operational consequence on the first administrator, and it records the measurement the issue's proposed guard did not survive: <code>INSERT … SELECT … WHERE NOT EXISTS</code> <strong>is not atomic</strong> under READ COMMITTED (two sessions, two rows), while <code>LOCK TABLE auth_users IN EXCLUSIVE MODE</code> before the same predicate serializes the decision (the second call waits, inserts nothing, answers 409). It also records why the steady state must answer before hashing — <code>needs_setup</code> is public, and the hash is ~42 ms measured — and the accepted cost: the lock closes the race, not the window.</li> <li><code>adr/index.md</code>, <code>adr/0009-authentication-and-authorization.md</code> — <strong>updated</strong>: the index row, and three "amended by" pointers where ADR 0009 states the rule that changed (§4's open surface, §6's first administrator, the "what an installer must read" consequence). The old text stays: an ADR is the record of what was decided, not the current count.</li> <li><code>guides/install.md</code> — <strong>updated</strong>: step 6 is now both doors to the same account (the first-run screen and the CLI), says why the CLI's password is temporary and the screen's is not, that both close forever once any account exists, and names the window; the container section gained the alternative to <code>exec</code>. Two sources were added so the claim is trackable — <code>setup_controller.py</code> and ADR 0011 — plus <code>SetupForm.tsx</code>.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same step and the same container paragraph.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: one clause in the authentication-limits bullet — the first account is a window, and <code>{"needs_setup": true}</code> on a reachable instance is an invitation. ADR 0009 and ADR 0011 were added to its <code>sources:</code> (the bullet cites both, and neither was declared).</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same clause.</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: one paragraph at the end of <em>Usuários</em>, because that section owns the account surface and claimed nothing about how the first account appears: it says the account on that screen is never the first one, and that after the first account the screen is the only surface that creates accounts. ADR 0011 joined its <code>sources:</code>.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph.</li> <li><strong>The sentences that did not move:</strong> the curation guide's screen count and menu count — the setup form is not a route (<code>SetupForm</code> renders in the shell's slot, like <code>LoginForm</code>), so the router still declares 25 screens and the menu 16 entries. No table, no column and no migration landed: the route reads <code>auth_users</code>, which the data-model page already documents, and the settings are unchanged.</li> <li><strong>Verified rather than assumed:</strong> the two new operations were exercised end to end against a throwaway database with <code>curl</code> — 422 on a weak password, 201 with <code>role: ADMIN</code>, <code>must_change_password: false</code> and a <code>HttpOnly; SameSite=lax</code> cookie, <code>/auth/me</code> answering with that cookie, 409 with <code>SetupAlreadyCompleteError</code> on the second call, and <code>needs_setup</code> flipping to false. The steady-state 409 costs ~1.7 ms, which is the cheap-read path and not a hash. A cross-origin POST to the setup route is refused 403 by <code>origin_guard</code>.</li> <li><strong>Not verified, and named here so nobody assumes otherwise:</strong> the setup screen itself was not looked at in a browser. Headless Chromium in this environment dumps core before rendering, so the evidence is <code>tsc</code>, ESLint, the Vite build and the bundle carrying the screen's text — not pixels. The Tailwind tokens used are the ones <code>LoginForm</code> already uses.</li> </ul> <h2 id="2026-10-09-recovery-gets-a-front-door">2026-10-09 — recovery gets a front door<a class="headerlink" href="#2026-10-09-recovery-gets-a-front-door" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>71781bf..6118170</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: a new <em>Recovering access</em> section, because the page owned the path in a single bullet of its known limits — "the administrator, or the CLI on the host" — which names it without saying how to walk it. The section states which door applies, the administrator's route on the accounts screen (temporary password, sessions ended, lockout lifted, no undo) and the host's route with the four CLI commands, and it records the two absences (no e-mail, no reset-token table). <code>domains/identity/cli.py</code>, <code>domains/identity/services/auth_service.py</code> and <code>api/controllers/users_controller.py</code> joined its <code>sources:</code>: the section is a claim about them and they were untracked.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the translation, same commands, and the two in-page anchors rewritten to the Portuguese the build emits (<code>#recuperar-acesso</code>).</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the page described the accounts screen and never the sign-in screen, which is where recovery now begins. A new <em>Signing in, and forgetting the password</em> says the form is not a route, that a failure is one sentence on purpose, and that the disclosure states the path without promising a message. The accounts section went from four facts to five, adding the self-reset that signs the administrator out and the row action that reaches the reset at all. <code>components/layout/LoginForm.tsx</code> and <code>PasswordChangeForm.tsx</code> joined its <code>sources:</code> — the <code>routes/**</code> glob already covered <code>UsersRoute.tsx</code>.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: both, in the SPA's vocabulary.</li> <li><strong>Verified rather than assumed:</strong> the two screens were <strong>rendered</strong>, not merely built — the API on a throwaway database, the built SPA served at the same origin, driven through CDP. The disclosure's four sentences are legible and the recovery line wraps cleanly; the accounts row shows the new <em>redefinir senha</em> beside <em>desativar</em>; clicking it opens the card and <code>document.activeElement</code> is the password input, so the focus claim in the code comment is measured and not asserted; the self-reset warning renders in the warn tone for the account's own row; and <code>bg-(--color-warn)/5</code> is in the emitted CSS as <code>color-mix(… var(--color-warn) 5% …)</code>, not as the invalid bracket form.</li> <li><strong>A method note for the next round:</strong> the full <code>chromium-<n>/chrome-linux64/chrome</code> renders here once <code>HOME</code>, <code>TMPDIR</code> and the two <code>XDG_*</code> variables point <strong>inside the workspace</strong>; pointed at the real <code>$HOME</code> it dumps core, which is the failure the previous entry recorded. The screenshots came from that binary, not from <code>chrome-headless-shell</code>.</li> <li><strong>What did not move:</strong> the screen count (25) and the menu count (16) — no route was added, and <code>LoginForm</code> renders in the shell's slot; the settings table — the issue forbids a new setting and none was added; the data model — no table, no column and no migration; and the API contract, which has carried the reset operation since ADR 0009.</li> <li><strong>Not carried, on purpose:</strong> the CLI's own <code>list</code>/<code>activate</code> behaviour beyond the two commands the recovery path needs. The install guide already owns the CLI as the bootstrap, and duplicating its whole surface here would create a second place to correct.</li> </ul> <h2 id="2026-10-09-the-interface-gets-one-name-per-screen-and-one-verb-per-action">2026-10-09 — the interface gets one name per screen and one verb per action<a class="headerlink" href="#2026-10-09-the-interface-gets-one-name-per-screen-and-one-verb-per-action" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>6118170..f731d2e</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the page named screens by their old headings, and the headings are now the menu's labels — <code>Diagnóstico</code> split into <em>Diagnóstico do arranjo</em> and <em>Saúde do sistema</em>, which is the ambiguity issue #23 was filed for. A new <em>One name per screen, one verb per action</em> states the copy canon the page now uses: <code>lib/screens.ts</code> as the one record per screen (route, label, hint) that the menu, the settings card and the <code><h1></code> all read, the header's four slots with the status line under the subtitle, and the verb table of <code>lib/copy.ts</code> — including why <code>Aposentar</code> (a catalogue row) and <code>Desativar</code> (an account) are deliberately two words. The prose followed the verbs: <code>Unificar</code> → <code>Mesclar</code>, <code>desativar</code> → <code>aposentar</code> for the <code>is_active</code> catalogues. <code>components/layout/PageHeader.tsx</code>, <code>lib/screens.ts</code> and <code>lib/copy.ts</code> joined its <code>sources:</code> — the section is a claim about all three and none was tracked.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation, same canon, in the SPA's vocabulary.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the report named it because its <code>sources:</code> carry the three <code>System*Route.tsx</code> files, and this round moved the health screen's label to <em>Saúde do sistema</em> and the run button's word to <code>Rodar agora</code>. The page names those screens by <strong>route</strong> (<code>/sistema/diagnostico</code>, <code>/sistema/workers</code>), never by label, so no claim in it stopped being true; the field the worker panel lost ("Quem está alterando") was never documented there. Nothing was edited to quiet the report.</li> <li><strong>Verified rather than assumed:</strong> every one of the 25 routes was <strong>rendered</strong>, not merely built — the API on a throwaway copy of the development database, the built SPA served from the same origin, screenshotted through CDP at 1440px. The screens show the heading equal to the menu label (<code>Categorias</code> / <em>As gavetas de assunto</em> / the count, in that order), the two renamed screens (<code>Saúde do sistema</code>, <code>Nível de descrição</code> → <code>Níveis de descrição</code> on the settings card and the heading), <code>Aposentar</code> where the catalogue row was <code>desativar</code>, <code>Rodar agora</code> where the same panel had <code>Executar agora</code> beside it, <code>Mesclar</code> where the merge panel said <code>Unificar</code>, and the notices drawing their ring and background after the move to <code>Notice</code>. The dossier renders its badges in the status slot with the record's own title above them.</li> <li><strong>Measured, not assumed:</strong> the tags screen was the only user of <code>bg-(--color-surface-2)</code>, a token <code>styles.css</code> does not define — 1 of the 9 tokens in use — and the box had no background; the two dead form labels (<code>Quem decide</code>, <code>Quem está alterando</code>) were labels with no field under them, left over from before the API took the actor from the session (ADR 0009). Both are gone, and the token one is now a gate.</li> <li><strong>What did not move:</strong> the screen count (25), the menu count (16) and the settings tables — no route and no setting was added; the data model — no table, no column and no migration; and the API contract, whose only change in this range is a Portuguese hint the inbox renders (<code>services/curation_service.py</code>), which is not part of the document.</li> <li><strong>Not carried, on purpose:</strong> the <code>words</code> field of the ban request is still documented in the contract as "normalizados ao gravar", the one place the old save verb survives. Changing it regenerates <code>openapi.json</code> and <code>schema.d.ts</code> for a description no screen renders — a decision of its own, recorded in the commit that left it.</li> </ul> <h2 id="2026-10-09-the-copy-pass-closes-its-own-gap">2026-10-09 — the copy pass closes its own gap<a class="headerlink" href="#2026-10-09-the-copy-pass-closes-its-own-gap" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>f731d2e..24ba826</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the page quoted the materialisation panel's button as "Conferir o que será feito", and the panel now says <strong>"Conferir impacto"</strong> like every other preview in the interface. One line, and it is the report's whole reason for naming the page.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same quote, plus the words the page had carried in English all along — <code>rungs</code> (nine times) is now <strong>degraus</strong>, <code>apply</code> is <strong>aplicação</strong>, and <code>dry-run</code> is <strong>prévia</strong>. The Portuguese page is the archivist's, and the SPA now names those things in Portuguese; a term in English that the interface already names in Portuguese is the defect the documenter rule names. The English page keeps <code>rung</code> and <code>apply</code>: they are the project's English words, the ones the API docstrings and <code>lib/hierarchy.ts</code> use.</li> <li><strong>Why this round exists:</strong> the previous entry closed the range with the copy pass declared complete, and it was not. <code>components/hierarchy/MaterialisationPanel.tsx</code> — a component and not a screen — kept <code>rung</code>, <code>apply</code> and <code>dry-run</code> through all of it, because the per-screen review never opened it and no gate looked. The commit that fixes it also adds the gate (<code>test_curator_copy.py</code>'s retired-word rule), which is why the page's quoted string is the only thing the report could see: the panel is not a <code>sources:</code> entry of any page.</li> <li><strong>What did not move:</strong> the screen count (25), the menu count (16), the settings tables and the data model — no route, no setting, no table and no migration; and the API contract, untouched by this range.</li> <li><strong>Not carried, on purpose:</strong> the retired-word list is deliberately short and its two omissions are documented in the test — <code>gravar</code> in the sense of <em>recording</em>, which is not the <code>Salvar</code> action, and <code>desativar</code>, which is right for an account and wrong for a catalogue row. Neither can be decided from the text, and a pattern that guessed would fail on correct prose.</li> <li><strong>The round found a hole in itself, by rendering.</strong> The gate's extractor read a JSX text node only up to the first newline, so a sentence the formatter wrapped was read as half a sentence: <code>só o apply absorve as tags</code>, on the second line of the proposals tab's warning, survived the review pass, the sweep and the gate's first version. The rendered screen showed it, the pattern now joins a wrapped node, and the extractor's sanity test asserts it does — so the hole is closed by a test rather than by a memory. Two states were rendered for this round and are the evidence for it: the plan screen's materialisation panel (<code>Conferir impacto</code>, <code>de 81 degraus decididos</code>, <code>degraus aprovados</code>, <code>a aplicação segue o vínculo</code>) and the tags proposals tab (<code>incluir no lote de aplicação</code>).</li> </ul> <h2 id="2026-10-09-the-shell-stops-scrolling-the-menu-away-and-the-footer-reaches-the-public">2026-10-09 — the shell stops scrolling the menu away, and the footer reaches the public<a class="headerlink" href="#2026-10-09-the-shell-stops-scrolling-the-menu-away-and-the-footer-reaches-the-public" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>24ba826..05819cd</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: a new <em>The screen is one window tall</em> states the layout the archivist now reads in — the shell is one viewport, only the content column scrolls, the header is sticky so the actions stay in reach, and the menu folds into one open section plus the one you are standing in, with <code>Início</code> standing outside any heading. <em>The menu collapses to icons</em> gained the one thing the accordion changes about it (collapsed, the accordion is off and all sixteen entries show) and the corrected claim about the footer: it renders at the foot of every screen <strong>including the three that come before a session</strong>, which was false when it was written.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same two sections, in the SPA's vocabulary.</li> <li><strong>Why the round exists, and what it measured rather than assumed:</strong> the rail used to grow to the height of the <em>page</em> (<code>min-h-full</code>), so on a long screen the whole menu scrolled out of view — at 1440x900 at the foot of <code>/entidades/lista</code>, a blank rail column and no menu at all — and the <code>overflow-y-auto</code> on the nav never engaged. After the fix, from the DOM: the document is 900px in a 900px window, the content column 3299px in 867px, and the nav 975px in 734px — which is what proved the accordion was still needed. After the accordion, the nav is <strong>734 in 734</strong>: the menu fits, and the active group is the one expanded.</li> <li><strong>The footer claim was false and is now true.</strong> <code>AttributionFooter</code>'s own docstring says a notice hidden behind a login is not a notice to the users of a network service, and the three screens before a session returned from <code>AppShell</code> before the shell existed — so the four §7(b) elements, and the version added in this range, were invisible to anyone without an account. Measured with the browser's cookies cleared: <code>/</code> now renders all four elements and <code>v1.0.0</code>.</li> <li><strong>What did not move:</strong> the screen count (25), the settings tables, the data model and the API contract — no route, no setting, no table, no migration and no contract change in this range. The menu keeps its <strong>16 entries</strong>; the accordion changed how they are shown, not how many there are.</li> <li><strong>Not carried, on purpose:</strong> the version comes from <code>pyproject.toml</code> and not from the API, because the built SPA is served by the API from the same commit — so the number is known when the bundle is written and a request would buy nothing. The API's own <code>info.version</code> is Litestar's default (<code>{"title": "Litestar API", "version": "1.0.0"}</code>) and was not a candidate.</li> </ul> <h2 id="2026-10-09-one-width-for-every-screen">2026-10-09 — one width for every screen<a class="headerlink" href="#2026-10-09-one-width-for-every-screen" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>05819cd..HEAD</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <em>The screen is one window tall</em> gains the width, because the width is part of the shell now: the column has <strong>no maximum</strong>, and what keeps a cap is the content (a paragraph's measure, a field's width).</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph.</li> <li><strong>Why:</strong> the per-screen caps had drifted — <code>max-w-5xl</code> on most screens, <code>max-w-4xl</code> on five, <code>max-w-6xl</code> on one, and four of the dossier's panels carried their own — so on a wide monitor two screens stopped growing at different points. That is not a decision, it is drift, and the report of it came from looking at the screens side by side. Measured after the change: at 1280 the content column is 1024 and the widest child is <strong>1024</strong>; at 1920 both are <strong>1664</strong>, on the four screens that used to differ (categories, anomalies, entities and the dossier).</li> <li><strong>What did not move:</strong> the text measure (<code>max-w-prose</code>, <code>max-w-3xl</code>) and the control widths (<code>max-w-md</code>, <code>max-w-xs</code>, the login forms' <code>max-w-sm</code>) — those are decisions about a paragraph and a field, not about a screen, and they are the same on every screen. The screen count, the menu count, the settings tables, the data model and the API contract are untouched.</li> </ul> <h2 id="2026-10-09-the-identity-enters-the-interface">2026-10-09 — the identity enters the interface<a class="headerlink" href="#2026-10-09-the-identity-enters-the-interface" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>05819cd..86796d0</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: a new <em>The identity is on screen</em> states the rail's navy, the logomark in both rail states (and that the cropped mark is the control that expands it), the 2px terracotta rule under a screen's header, and the plate on the two cards before a session. It also records <strong>why the accent stayed blue</strong>: the terracotta is <code>oklch(… 31)</code> and <code>--color-danger</code> is <code>oklch(… 25)</code>, six degrees apart, so a terracotta button and "Excluir" would have been one colour.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same section.</li> <li><code>guides/install.md</code> — <strong>updated</strong>: the first-run card carries the logomark, because it is the first screen an installer sees. One sentence, and it is what the report's second page was for.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same sentence.</li> <li><strong>Why:</strong> the interface and the logomark shared no colour. Measured before the change: the identity is terracotta on navy (<code>#C4503F</code> on <code>#14202B</code> = <code>oklch(0.580 0.152 31)</code> on <code>oklch(0.238 0.027 247)</code>) and the theme's accent was <code>oklch(0.52 0.13 250)</code>, a blue. The navy turned out to share the accent's <strong>hue</strong> (247 against 250), which is what let the rail take the identity's dark without re-tuning the semantic colours.</li> <li><strong>Verified, rendered:</strong> the rail expanded and collapsed and the sign-in card at 1440x900; the tab computes to <code>oklch(0.58 0.152 31)</code>, the rail to <code>oklch(0.238 0.027 247)</code>, the wordmark to <code>"PT Serif"</code> at 45px with <code>document.fonts.check('400 45px "PT Serif"')</code> true, the header rule to <code>2px oklch(0.58 0.152 31)</code>, and <code>/favicon.svg</code> answers 200 and is in <code>dist</code>.</li> <li><strong>What did not move:</strong> the screen count (25), the menu count (16), the settings tables, the data model and the API contract — no route, no setting, no table, no migration and no contract change. The accent, the danger, the warn and the ok colours are unchanged: the identity's terracotta is a brand colour, and the copy gate still holds every string.</li> <li><strong>Not carried, on purpose:</strong> the four identity SVGs stay as design sources under <code>apps/curator/src/assets/brand/</code> while <code>Mark</code>/<code>Lockup</code> inline the two shapes the SPA renders. The geometry is therefore written twice — a file for the designer and a component for the page — and the alternative, generating the components from the files, would put a build step in the middle of a logo. <code>marca-escura.svg</code> was reconstructed from the family's geometry and is the one file a person should look at rather than trust.</li> </ul> <h2 id="2026-10-09-the-drawer-and-the-cluster-become-the-words-the-contract-already-used">2026-10-09 — the drawer and the cluster become the words the contract already used<a class="headerlink" href="#2026-10-09-the-drawer-and-the-cluster-become-the-words-the-contract-already-used" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>86796d0..571c6cb</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <code>drawer</code> is <strong>category</strong> (16 times) and the screen is <code>Descobrir Categorias</code>, which is what the rail says. <code>cluster</code> stays: it is the API's own word for a proposed grouping (<code>clusters_found</code>, <code>TRIGRAM/PLURAL/MIXED</code>).</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: <code>gaveta</code> is <strong>categoria</strong> (17 times), the same rename.</li> <li><strong>Why:</strong> the interface had its own metaphor for a concept the contract had already named. The API's field is <code>category</code> — <code>category_would_be_lost</code>, <code>macro_category_name</code>, <code>CATEGORY</code> — and the drawer was the interface's word for it, so a reader of both had two names for one thing. Measured before the change: <strong>34 visible strings</strong> in the SPA used <code>gaveta</code>/<code>cluster</code> (Categories 9, Tags 11, Document 7, taxonomy 4, Discover 3). None remain, and the gate now holds both words out.</li> <li><strong>The API was split too, and is now aligned:</strong> <code>hierarchy_requests.py</code> described a field as "Documentação da gaveta para o curador" (a Portuguese <code>description</code> in the contract) and two worker messages said "formar clusters semânticos". Both say <code>categoria</code>/<code>agrupamentos</code> now, and the contract was regenerated — <code>openapi.json</code> and <code>schema.d.ts</code> change by exactly that one description.</li> <li><strong>What did not move:</strong> the screen count, the menu count, the settings tables, the data model and the API's <em>shape</em> — no route, no setting, no table, no migration and no field renamed. The change is a word, and the only contract diff is a description's text.</li> <li><strong>Not carried, on purpose:</strong> the <strong>casing of the labels and hints</strong>. The pass over them is half applied — some are English-style Title Case ("Lista e Busca", "Facetas e Filtros") and most are sentence case ("Onde está incoerente", "A trilha do que foi excluído") — and finishing it one way or the other is 25 strings. English-style Title Case applied to Portuguese capitalises verbs ("O que <strong>P</strong>recisa de <strong>M</strong>im <strong>H</strong>oje", "Não <strong>É</strong> Assunto"), which the pt-BR convention does not; the preview of that direction is in the pull request rather than in this commit.</li> </ul> <h2 id="2026-10-10-the-documentation-site-published-from-ci-and-dressed-in-the-identity">2026-10-10 — the documentation site, published from CI and dressed in the identity<a class="headerlink" href="#2026-10-10-the-documentation-site-published-from-ci-and-dressed-in-the-identity" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>571c6cb..236bc58</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the new <em>The documentation site</em> section (the deploy path and the recovery, ADR 0012), plus the vocabulary the drawer round left behind — <code>drawer</code> four times and one <code>gaveta</code> inside the measurement SQL of an English page. <code>ci.yml</code> and <code>mkdocs.yml</code> joined its <code>sources:</code>, so a change to the publish path reports the page stale.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same section, and the same five <code>gaveta</code>.</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the sign-in plate is no longer centred and carries no tagline (<code>bd89df2</code>, in this range), and the warning still said <code>"sem gaveta"</code> in the <strong>canonical</strong> page while its own translation said <code>"sem categoria"</code>.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the identity paragraph, which carried the same "centralizado" that stopped being true, in the same sentence.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: <code>drawer</code> in five places, including the section heading and the <code>archive_macro_categories</code> row.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same five, in Portuguese.</li> <li><strong>The report named two pages; the round touched six, and that is the finding.</strong> The freshness report is computed from <code>sources:</code>, so it can see <code>LoginForm.tsx</code> moving under <code>curate.md</code> and the worker strings moving under <code>operate.md</code> — it cannot see a <strong>word</strong> that no source file owns. The previous round renamed the concept in <code>curate.md</code> and its translation and stopped there: <code>operate.md</code>, <code>data-model.md</code> and both translations still said <code>drawer</code>/<code>gaveta</code>, and one line of <code>curate.md</code> kept the old string after the paragraph around it was rewritten. The check that found them was <code>grep -rn "gaveta\|drawer" docs/guides/</code>, run by hand, and it is worth running after the next rename — nothing in the suite would have failed.</li> <li><strong>A correction in the ledger itself, in this diff.</strong> The entry above ended its range at <code>HEAD</code>, which <code>_RANGE</code> cannot parse, so <code>ledger_floor</code> fell back to the range before it and the report re-flagged pages that round had already triaged. It now names the commit it meant (<code>571c6cb</code>), and this entry starts where it ends.</li> <li><strong>The gate this change adds, and why it is a test.</strong> <code>mkdocs build --strict</code> exits 0 when <code>theme.logo</code>, <code>theme.favicon</code> and <code>extra_css</code> all point at files that do not exist — measured, not assumed. <code>testing/unit/docs/test_docs_brand.py</code> therefore pins the wiring (an asset named and not present fails), the copies (byte for byte against <code>apps/curator/</code>, so the identity cannot drift), the palette (against the tokens of <code>apps/curator/src/styles.css</code>, read out of that file rather than repeated) and the <code>url(...)</code> the stylesheet reaches for.</li> <li><strong>Not carried, on purpose:</strong> <code>guides/install.md</code>, <code>index.md</code> and the guides not named above — nothing in the range touches what they document, and <code>adr/</code> is not a sourced surface (ADR 0010).</li> </ul> <h2 id="2026-10-10-the-worker-settings-leave-the-operations-panel-issue-53">2026-10-10 — the worker settings leave the operations panel (issue #53)<a class="headerlink" href="#2026-10-10-the-worker-settings-leave-the-operations-panel-issue-53" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>236bc58..89e6a43</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: a new <em>Configuração dos Workers — <code>/configuracoes/workers</code></em>, and <em>Workers de IA</em> reduced to the machine view — the queue and running one now — with the effective configuration explicitly <strong>read only</strong>. The Configurações table and the <em>Operação</em> card row gain the fourth screen.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same two sections, in the SPA's vocabulary.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: <code>GET /api/v1/system/workers/settings</code> joins the route table, the SPA table gains <code>/configuracoes/workers</code>, and the polling paragraph says why the new read exists — it resolves the same configuration and counts no queue. The <code>ci.yml</code> change of <code>41fffc7</code> (dropping <code>configure-pages</code>) is <strong>no-change</strong> here: the page describes the artifact and the deploy job and never named the dropped step.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same two tables and the same paragraph.</li> <li><strong>Why the round exists:</strong> issue #53 split a screen that answered two questions — what the machine is doing and what the installation is <em>set</em> to do. The API gained exactly one read (<code>WorkerOperationsService.list_settings</code>, over the same <code>_resolved_workers</code> traversal the panel uses) because the panel's <code>GET /system/workers</code> counts nine queues and the configuration screen shows none of them. The precedence (<code>explicit argument > the archive_worker_settings row > the signature default</code>) and the refusal of <code>config</code> as an option are unchanged; the split moved where the write is made, never what it means.</li> <li><strong>What did not move:</strong> the data model and the migrations — no table, no column, no migration and no settings field. <code>packages/api-contract/openapi.json</code> and <code>schema.d.ts</code> move by exactly one route and its two schemas (<code>SystemWorkerSettingsResponse</code>, <code>WorkerSettingsItemDTO</code>).</li> <li><strong>Verified, rendered:</strong> the built SPA served by the API on a migrated throwaway database, looked at with the headless shell. <code>/configuracoes/workers</code> renders its heading and the nine cards (each with <code>padrão do código</code> and the effective engine/preset/config), and expanding the NER card shows the Engine and Preset selects, the batch field, the JSON options field and <em>Salvar padrão</em> / <em>Voltar ao padrão do código</em>, with no runtime exception. <code>/sistema/workers</code> renders the same nine rows with <strong>only</strong> <em>Rodar agora</em> — no <em>Configurar</em> — and the engine and preset still on the collapsed row. The throwaway database's schema was dropped and the server stopped after the check.</li> <li><strong>Not carried, on purpose:</strong> <code>guides/install.md</code>, <code>index.md</code> and <code>guides/data-model.md</code> — the round adds no setting field, no table and no install step, and install's sources did not move.</li> </ul> <h2 id="2026-10-10-the-pre-release-pass-for-110">2026-10-10 — the pre-release pass for 1.1.0<a class="headerlink" href="#2026-10-10-the-pre-release-pass-for-110" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>89e6a43..b291d36</code></li> <li><strong>Pages:</strong></li> <li><code>guides/install.md</code> — <strong>no-change</strong>: the round moves the version in <code>pyproject.toml</code> and the curator's <code>package.json</code>, and this page documents the versions of the <strong>tools</strong> (Docker, <code>uv</code>, Bun, Python) and the settings — never the release the checkout is on. Nothing it states stopped being true: the version an installation runs is read from <code>pyproject.toml</code> at build time and shown in the page footer, not written into this page.</li> <li><strong>Why the round exists:</strong> milestone 1.1 closed, so this is the release preparation the release section of <code>CONTRIBUTING.md</code> asks for in one pull request — the version, the changelog, and the claims that had drifted while no gate looked at them.</li> <li><strong>What the report cannot see, and what found it.</strong> The freshness report is computed from <code>sources:</code>, so it saw <code>pyproject.toml</code> move under <code>install.md</code>; it cannot see a page with no <code>sources:</code> (<code>README.md</code>, <code>docs/index.md</code>) and it cannot see a <strong>number</strong> inside a page whose sources did not move. The three that were wrong came from reading the claims against the code: the README's screen table listed 24 of the 26 routes of <code>router.tsx</code> and its status line still said <code>1.0.0</code>; <code>guides/curate.md</code> said <strong>25</strong> screens where <code>router.tsx</code>, <code>ScreenId</code> and <code>SCREENS</code> all carry <strong>26</strong> (measured: the three sets are equal, and issue #53 added the twenty-sixth while the round that landed it recorded the count as unchanged); the documentation index said "Scrinalia is at 1.0". The fourth came from the check the ledger of the rename round asked for: <code>grep -rn "gaveta\|drawer" README.md docs/guides/</code> — the README still said <code>drawer</code> five times (the subject-axis paragraph and two screen rows) because that round swept the guides and stopped there. It is the second time the same rename was finished page by page, and the reason is the same: <code>drawer</code> is a word, and no <code>sources:</code> list owns a word.</li> <li><strong>The one number the release moved that no page owned.</strong> <code>info.version</code> was Litestar's <code>DEFAULT_OPENAPI_CONFIG</code> — <code>title="Litestar API"</code>, <code>version="1.0.0"</code> — because <code>create_app()</code> never passed an <code>openapi_config</code>; measured with the project already at 1.1.0, the served configuration was unchanged. <code>api/openapi.py</code> now derives both from the project, and <code>testing/unit/api/test_openapi_metadata.py</code> fails when the served document, the installed distribution, <code>pyproject.toml</code> and the committed <code>openapi.json</code> disagree. <code>guides/curate.md</code> is the page that declares the contract as a source, and it was touched in this round for the screen count, so the report stayed quiet — correctly: the route delta was empty, because the change adds no route and removes none.</li> <li><strong>Not carried, on purpose:</strong> the ADRs. <code>0010</code> still records "24 screens in <code>router.tsx</code>" as the measurement it was ratified against, and <code>0012</code> still quotes the index's old "at 1.0" as the example of a claim about a release. An ADR records the reasoning at the time, and neither number is a rule the code has to satisfy. <code>cluster</code> also stays, in the README's Discover row and in the guide: it is the API's own word for a proposed grouping (<code>clusters_found</code>, <code>TRIGRAM/PLURAL/MIXED</code>), which is the distinction the rename round drew and this one keeps.</li> </ul> <h2 id="2026-10-10-the-collection-pagination">2026-10-10 — the collection pagination<a class="headerlink" href="#2026-10-10-the-collection-pagination" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>b291d36..b99408e</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the <em>Lista e busca — <code>/acervo/lista</code></em> section says "the URL is the state, so a filtered list is shareable and the back button works", lists the facets and states that nothing is written. It never describes the page buttons, and the fix makes "Próxima" obey the sentence that was already there — a screen changed one expression, not a decision.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the <code>/sistema/execucoes</code> row describes the ledger, the failures of the last 30 days grouped by root cause and the click that filters the ledger to its occurrences; it never states how the ledger pages. The expression that moved keeps the page a caller sends and returns to the first page for a filter, which is what the row already implies.</li> <li><strong>Why the round exists:</strong> the freshness report named both pages after <code>e89bfbb</code>/<code>b99408e</code> touched four route files. Two <code>no-change</code> verdicts are the honest outcome here: a defect that stopped a documented behaviour from happening is repaired in the code, and a page does not change because the code started keeping a promise the page already made.</li> <li><strong>What the round measured.</strong> <code>/acervo/lista</code> builds its request from the URL (<code>CollectionRoute.tsx</code>, <code>toRequest</code>), so the page's claim was true of the read and false of the two buttons; the gate that keeps it true is <code>testing/unit/curator/test_curator_search_state.py</code>, which fails on an object that spreads <code>changes</code> and then writes a literal <code>offset</code>. The three other screens with the same fold (<code>DeletionsRoute</code>, <code>SystemRunsRoute</code>, <code>DiagnosticsRoute</code>) are normalized in the same change.</li> </ul> <h2 id="2026-10-12-the-entity-merge-and-the-normalized-identity">2026-10-12 — the entity merge and the normalized identity<a class="headerlink" href="#2026-10-12-the-entity-merge-and-the-normalized-identity" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>b99408e..c1a6fbf</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <em>Entidades — <code>/entidades/lista</code></em> section justified the pair list's two ids with "two rows can read the same name". That sentence described the defect: case twins scored 1.000 on the trigram scan (<code>#1167 A. B. Deter ≈ #92610 a. b. deter</code>). The migration reconciles them, so no two entity rows share a normalized name any more and the reason now reads "almost the same name" — the similarity list is about spellings that are close, not identical.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the <em>Subjects, categories and entities</em> table marks <code>archive_tags.name</code> and <code>archive_entities.name</code> as unique, which is still true, and said nothing about the identity rule the schema now enforces. One paragraph states it: <code>lower(btrim(name))</code> is unique on both tables next to the unique <code>name</code>, and the insert path normalizes the spelling before it looks an existing id up.</li> <li><strong>Why the round exists:</strong> the freshness report named both pages after <code>eae9943</code> and <code>c1a6fbf</code>, the two commits of issue #68 — the shared synonym upsert and the reconciliation migration.</li> <li><strong>What the round measured.</strong> The migration was replayed on a <code>pg_dump</code> copy of the reference collection before it touched the collection: 8 322 entities with 3 523 twin groups and 82 317 links became 4 799 entities with 0 twin groups, 0 non-normalized names and 64 504 links — exactly the number of distinct <code>(description_id, survivor)</code> pairs computed before the run, with the 266 synonyms intact and none dangling and the surviving ids equal to the predicted ones. The upgrade then ran on the collection itself and measured the same numbers, and <code>alembic check</code> reports no drift on both. Not looked at in a browser: the evidence is the integration tests (the merge of the issue's cluster, the dedup on both axes, the index rejecting a case twin), <code>basedpyright</code> and that measurement.</li> </ul> <h2 id="2026-10-12-the-tag-decision-queue-and-the-category-expansion">2026-10-12 — the tag decision queue and the category expansion<a class="headerlink" href="#2026-10-12-the-tag-decision-queue-and-the-category-expansion" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>c1a6fbf..820f3c9</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <em>Tags</em> route had four tabs and the page said so ("One route, four questions"). The fifth, <strong>Classificar</strong>, is the subject decision itself, so the page gains a subsection for it: the <code>GET /tags/catalog</code> read and its <code>subject_state</code> partition, the three destinations of one tag (<code>PATCH</code> into a category, <code>PATCH</code> with <code>null</code>, <code>PUT</code>/<code>DELETE</code> on the facet), the durability of "not a subject" through <code>domain_subject_exclusions</code>, and the fact that the selection writes the same three. The <em>Categorias</em> section gains the expansion it never had: the two tabs of a card, the list read through <code>macro_category_id</code> with the badge and the <code>total</code> being one definition, and the read-only preview with its <code>Editar</code>. The summary table's two rows name the new decisions.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the facet paragraph said "nothing writes a facet automatically" and named no writer, which was true and incomplete — the table had none at all. One clause now names the act (<code>PUT /tags/{id}/facets/{facet_type}</code>, undone by <code>DELETE</code>) and the two things it settles with it: the drawer it empties and the subject exclusion it registers.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: its only source in the range is <code>SetupForm.tsx</code>, and the paragraph that describes it says the first-run card carries the logomark at the top "on the same plate the sign-in card has". The commit made the two plates identical — it dropped the tagline the sign-in card had already dropped and moved the padding inside the card — so the sentence became truer, not staler. Nothing the page states about the setup flow, the routes or the closing of both doors moved.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: <code>e2ecd04</code> memoizes the once-only log sink (the page describes what is written, not how the sink is held), <code>4ff8144</code> reads the URL alphabet of <code>worker_ner</code> from the RFC (the page documents the panel and the ledger, never the character classes), <code>bc7fc07</code> only gives the port stubs a body that has an effect, and from #64 the page's sources gained <code>TagCatalogState</code> in <code>models/enums.py</code> and one <code>validateSearch</code> in <code>router.tsx</code> — a subject-axis filter and an SPA route, neither of which the operations panel reads.</li> <li><strong>Why the round exists:</strong> the freshness report named four pages after 30 commits landed on <code>dev</code> since the last entry, three of them the work of issues #64 and #65, and it printed the three new operations (<code>GET /tags/catalog</code>, <code>PUT</code>/<code>DELETE /tags/{id}/facets/{facet_type}</code>) beside <code>guides/curate.md</code>.</li> <li><strong>What the round measured on the collection.</strong> On the reference collection (<code>memoriacuritibana</code>: 8 103 tags, 55 345 links, 5 subject exclusions, 0 facets) the partition holds and the numbers are those of the change: <code>PENDING</code> 7 407 + <code>NOT_SUBJECT</code> 3 + <code>FACET</code> 0 = 7 410, exactly the tags with <code>macro_category_id IS NULL</code> that the card used to count. So the card's number barely moves <em>today</em> — what the change makes reachable is the other 7 407, which had no screen and no durable write. The new predicate costs 4.43 ms against the old column's 0.87 ms (both a sequential scan of 8 103 rows; the two <code>NOT EXISTS</code> read a 0-row table and a 5-row one), and the catalogue's first page — the page, its total aside — plans and executes in 19.6 ms with <code>limit 25</code> over the grouped link count. Neither is a home-screen cost.</li> <li><strong>What the round looked at.</strong> The two screens were <strong>looked at</strong>, not inferred: the built SPA was rendered with the dev stack (Vite on <code>:5173</code>, the API on the zeroed database) through the headless shell, with scratch rows seeded into that database — 7 tags, 40 descriptions, 79 links, one facet and two exclusions — and removed again afterwards, because a zeroed collection shows no row to judge. That found one defect no gate could see: the batch bar's drawer select inherited the bar's white text on its own white background, so its placeholder rendered invisible. Measured with <code>getComputedStyle</code>: <code>color</code> and <code>background-color</code> were both <code>rgb(255, 255, 255)</code> before the fix and <code>oklch(0.24 0.012 60)</code> on white after it. The rest of what the pixels confirmed: <code>PENDING</code> listed 4 tags with the classifier's confidence beside each, <code>FACET</code> listed <code>rua exemplo</code> with the <em>Lugar</em> badge and its "tirar de lugar" button, the category card's badge said 12 descrições while the list answered "1–10 de 12", the read-only panel showed the title, both badges, the ISAD(G) fields and every tag with its category, and the card's <code>Editar</code> tab rendered the form with <code>Salvar</code> disabled while nothing was dirty. <strong>Not</strong> verified: the collection's own screens. The API was pointed at the zeroed database, so the pixels are of a seeded scratch collection, not of <code>memoriacuritibana</code>; what was measured there was the SQL, read through <code>psql</code>, not the screen.</li> </ul> <h2 id="2026-10-12-the-review-of-the-decision-queue-no-page-changed">2026-10-12 — the review of the decision queue (no page changed)<a class="headerlink" href="#2026-10-12-the-review-of-the-decision-queue-no-page-changed" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>820f3c9..b759393</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the range <em>is</em> the review of the change the previous entry documented. The offset clamp answers 200 where the route answered 500 (<code>offset=-1</code>), the contract pair moved only in the docstring of <code>TagCatalogState</code>, and the four curator fixes changed the search box's debounce guard, the counts rendered while <code>placeholderData</code> held the previous filter, the row's select (retired categories now included and marked) and the batch's report of what it wrote and what did not answer. No sentence of the page described any of those behaviours as they were — and the page already said the batch "reports how many tags it wrote and where", which the fix made true for the failure case too.</li> <li><strong>Why the round exists:</strong> the two <code>fix</code> commits landed <em>after</em> the documentation commit, which is exactly the signal the report is for: a page whose sources moved is a candidate, and a candidate is closed with a reason rather than an edit.</li> </ul> <h2 id="2026-10-13-the-collision-batch">2026-10-13 — the collision batch<a class="headerlink" href="#2026-10-13-the-collision-batch" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>b759393..0fd528e</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the collisions screen gained the category filter (<code>entity_type</code>, the same read the batch selects with) and the batch itself, <code>POST /api/v1/taxonomy/conflicts/resolve/batch</code>. A new subsection, <em>Deciding a category at once</em>, says what the unit of the write is (the pair, never the selection), why <code>skipped</code> is not <code>failed</code>, and why <code>remaining</code> is the same read taken twice. The <code>Pendentes</code> bullet now names the filter, and the summary table's row says the decision covers one pair or a whole category, with one ledger row per pair.</li> <li><strong>Why the round exists:</strong> the freshness report named one page after the two commits landed on the branch — the API route and the SPA screen — and printed the one new operation, <code>POST /api/v1/taxonomy/conflicts/resolve/batch</code>, beside <code>guides/curate.md</code>. The range also carries three commits no earlier entry had closed (<code>1f28807</code>, <code>7012898</code>, <code>e745466</code>); the report named no page for them, so they are closed here by absence.</li> <li><strong>What the round looked at.</strong> The screen was <strong>looked at</strong>, not inferred: the built SPA was served by the API against the zeroed collection with a scratch account (created for the round, deleted after), and the headless shell rendered the route. Measured in the pixels: the category row (<code>todas as categorias</code> / <code>Local</code> / <code>Pessoa</code> / <code>Organização</code>) with <code>nomes idênticos</code> selected; the batch card reading "80 par(es) · nomes idênticos"; and the panel opening on a click, with the note field and the two verdict buttons. With <code>?tipo=exact_name&categoria=LOC</code> the header, the card and the list move together — 36 collisions, all <code>Local</code> — which is the selection the batch writes. <strong>Not</strong> verified in pixels: the write itself. Resolving a category from the screen would have destroyed 36 pairs of the working collection, so the write is verified by the integration suite, which drives the real route and the real ledger (814 integration tests passed); the screenshots show the selection and the panel, not a resolution.</li> </ul> <h2 id="2026-10-13-the-entity-merge-ledger-issue-34">2026-10-13 — the entity merge ledger (issue #34)<a class="headerlink" href="#2026-10-13-the-entity-merge-ledger-issue-34" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>0fd528e..318f8e0</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <em>Mesclar</em> bullet of <em>Entidades — <code>/entidades/lista</code></em> said entities had "no proposal catalogue and no ledger", so there was no undo and no preview route. It now names the preview (<code>POST /api/v1/taxonomy/entities/merge/preview</code>, the plan the write executes), the ledger (<code>GET /api/v1/taxonomy/entities/merge-log</code>, one row per absorbed entity) and the undo (<code>DELETE /api/v1/taxonomy/entities/merge-log/{merge_id}</code>), and the summary table's row says the merge is reversible. The entity merge also <strong>left</strong> the <em>What has no way back</em> warning: it is no longer one of the writes that announce they have no undo.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the subjects-and-entities table gained the <code>archive_entity_merge_log</code> row — one row per absorbed entity, snapshotted before anything changes, and what <code>undo_entity_merge</code> restores from it.</li> <li><strong>Why the round exists:</strong> the freshness report named both pages after the two code commits landed on the branch (the API and the SPA screen) and printed the three new operations beside <code>guides/curate.md</code>: <code>POST /entities/merge/preview</code>, <code>GET /entities/merge-log</code> and <code>DELETE /entities/merge-log/{merge_id}</code>. The range also carries <code>a63b6c9</code> and <code>402d657</code> (the collision batch's documentation and API commits), which no earlier entry closed; the report named no page for them, so they are closed here by absence.</li> <li><strong>What the round measured.</strong> The write and its reverse are pinned by the integration suite, which drives the real repository and the real ledger: the undo restores the row, the per-entity links and the spelling state exactly, removes only the links the merge created (<code>created_link_ids</code>) and puts back the canonical name the optional rename changed; the preview and the apply are asserted to produce the same numbers; and the <code>pair_kind</code> partition of the conflict routes, which this change must not disturb, keeps its own tests green. Measured: 1 511 tests passed, <code>basedpyright</code> 0 errors, <code>alembic check</code> reports no drift after the new table.</li> <li><strong>What the round looked at.</strong> The screen was <strong>rendered</strong>, not inferred: the built SPA was served by the API (<code>create_app()</code> mounts <code>apps/curator/dist</code>) against the reference collection, with a scratch session created for the round and deleted after, and the headless shell drove <code>/entidades/lista</code> over CDP. Seen in the pixels: the list with the new sentence ("mesclar tem desfazer: cada entidade absorvida entra no ledger antes de a escrita acontecer"); the merge panel opened by marking two rows, reading "33 descrição(ões) passam a carregar <strong>smu</strong> · 33 vínculo(s) reescrito(s) · 1 entidade(s) absorvida(s)" — the planner's real numbers over the real collection, not a copy — with the updated warning below it; and the new <em>Ledger dos merges de entidades</em> section at the bottom, with its server-side search box and the empty state ("Nenhum merge aplicado ainda"). <strong>Not</strong> verified in pixels: a merge actually written and undone from the screen. Applying one would have written to the reference collection's ledger, so the write rests on the integration suite, which drives the real repository and the real ledger.</li> </ul> <h2 id="2026-10-13-an-entity-opens-into-its-descriptions-issue-86">2026-10-13 — an entity opens into its descriptions (issue #86)<a class="headerlink" href="#2026-10-13-an-entity-opens-into-its-descriptions-issue-86" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>08500a9..HEAD</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <em>Entidades — <code>/entidades/lista</code></em> gained the read half of the screen, which it never described. A name expands into the <strong>descriptions that carry it</strong> (<code>GET /api/v1/documents</code> with the new <code>entity_id</code> filter, paged ten at a time inside the card); the badge above and the list's <code>total</code> are one number counted twice, pinned by <code>testing/integration/api/controller/test_entity_documents.py</code>; a row opens the read-only preview the category screen already uses, and its "Editar" lands on the dossier's <strong>Assuntos</strong> tab, which is where a description's entities are added and removed. <code>?aberta=</code>, <code>?offset=</code> and <code>?descricao=</code> are named as the state the URL carries.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation of the same paragraph.</li> <li><strong>Why the round exists:</strong> the section listed the screen's decisions and said nothing about reading it, while the card promised "3.152 descrições" and the screen had no way to show which ones. The freshness report named <code>guides/curate.md</code> for the SPA and API commits of this change.</li> <li><strong>What the round measured.</strong> The pairing was measured on the <strong>reference collection</strong>, through the routes the screen calls and not against a fixture: <code>entity_id=10</code> (<em>Curitiba</em>) answers <code>total=3152</code> where the card shows <code>total_usage=3152</code>, and <code>2679</code>/<code>2670</code> answer <code>2467</code> against <code>2467</code>. Measured: 1 513 tests passed, <code>basedpyright</code> 0 errors, <code>alembic check</code> reports no drift (the change adds no migration), and the SPA's <code>tsc</code>, ESLint and Vite build are green. The new test seeds one description carrying <strong>two</strong> entities on purpose, so the join it forbids would answer 2 where the card promises 1 — the same trap <code>test_category_documents.py</code> pins for the drawers.</li> <li><strong>Found and deliberately not fixed.</strong> <code>EntityRepository.get_relevance_count</code> <strong>inner-joins</strong> the link table, so the ranking never lists an entity that no description carries: <code>EntityRow</code>'s orphan branch, and the per-row <em>Excluir</em> button with it, is unreachable from the Relevância tab. That is a separate defect about a separate decision (whether the ranking should left-join and list orphans at zero), so it was left alone and pinned as measured in <code>test_an_orphan_is_not_ranked_and_its_expansion_is_empty</code>.</li> <li><strong>What the round looked at.</strong> The screen was <strong>rendered</strong>, not inferred: the built SPA served by the API (<code>create_app()</code> mounts <code>apps/curator/dist</code>) against the reference collection, with a scratch session created for the round and deleted after, and the headless shell driven over CDP. Seen in the pixels: <code>/entidades/lista?aberta=10</code> with the <em>Curitiba</em> card open, the badge "3.152 descrições" above the expansion's own "1–10 de 3.152", the controls (checkbox, <code>tipo</code>, <em>Excluir</em>) outside the toggle and the name and badges inside it — which is the click that used to mean two things; the preview panel over the list, rendering the shared <code>DescriptionPreview</code>; and <code>/assuntos/categorias?aberta=4</code> still rendering its own expansion after the component was lifted out of <code>CategoriesRoute.tsx</code> ("2.312 descrições" against "1–10 de 2.312"). Read from the DOM and not inferred: the entity panel's <em>Editar</em> carries <code>href="/acervo/53617?aba=assuntos"</code>, and the category panel's still carries <code>?aba=descricao</code>. <strong>Not</strong> verified in pixels: the empty expansion ("Nenhuma descrição carrega este nome"), unreachable from this screen for the reason above, and any write — the round only read.</li> </ul> <h2 id="2026-10-13-renaming-a-name-with-an-undo-issue-85">2026-10-13 — renaming a name, with an undo (issue #85)<a class="headerlink" href="#2026-10-13-renaming-a-name-with-an-undo-issue-85" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>6c79880..HEAD</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <em>Entidades — <code>/entidades/lista</code></em> gained its fourth decision, <strong>Renomear</strong> (<code>PATCH /api/v1/taxonomy/entities/{entity_id}</code>), with the three facts the screen has to state: the previous spelling stays anchored as a synonym unless <code>keep_old_synonym</code> is turned off, the write leaves one <code>RENAME</code> row in the merge's own ledger so the ledger's "desfazer" reverses it, and a spelling another entity already carries is a <strong>409</strong> that names the twin and points at the merge. The summary table's row now says the rename is reversible, and the read paragraph names the card's second tab, which is where the rename lives.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation of the same.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the <code>archive_entity_merge_log</code> row now says the table holds a <strong>merge</strong> (one row per absorbed entity) or a <strong>rename</strong> (one per name), and names <code>kind</code> as the checked discriminator the undo branches on.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the translation of the same.</li> <li><strong>Why the round exists:</strong> the name was the one field of the entity vocabulary with no write of its own. The rename already existed in the repository and the service — with a test, and shared with the merge's optional rename — but no route reached it, and the screen's only rename field lived <em>inside</em> the merge panel, so a plain typo had no path that did not absorb another entity. The freshness report named <code>guides/curate.md</code> for the API and SPA commits of this change.</li> <li><strong>What the round measured.</strong> Measured: 1 524 tests passed, <code>basedpyright</code> 0 errors, and the migration applied, reversed and re-applied on the reference database with <code>alembic check</code> reporting no drift. The write and its reverse are pinned by the integration suite against the real repository: the rename leaves exactly one <code>RENAME</code> row (no absorbed entity, no links), the undo restores the name and drops the spelling the rename planted, <code>keep_old_synonym=False</code> writes no spelling at all, the collision raises <code>DuplicateEntityNameError</code> with the twin in <code>details</code> <strong>before</strong> the write, and a <strong>merge with a rename leaves one row per absorbed entity and no <code>RENAME</code> row</strong> — the trap the shared writer would otherwise have created. <code>refusalMessage</code> is what turns that payload into the sentence, so the copy the archivist reads is the interface's, not the server's.</li> <li><strong>What the round looked at.</strong> The screen was <strong>rendered</strong> against the reference collection (<code>scrinalia_zeroed</code>, the ~100-description collection, already at head): the built SPA served by the API, a scratch session created for the round and deleted after, and the headless shell driven over CDP. Seen in the pixels: <code>/entidades/lista</code> with the row's <strong>Renomear</strong> in <code>actions</code>, outside the toggle, and the card opened by it on the <strong>Editar</strong> tab, with the name input and the "manter a grafia antiga como sinônimo" box checked; the same form refusing <code>curitiba</code> with "Já existe uma entidade chamada \"curitiba\". Mescle as duas em vez de renomear." and the request reference, with the row still reading <code>paço da liberdade</code> — the refusal happened before the write; and the ledger card, reading <strong>"paço da liberdade → paço da liberdade de curitiba · renomeação · 13/10/2026, 15:21 · por Cassio"</strong>, which is the rename applied through the route on that collection. The <strong>desfazer</strong> was then clicked <em>on the screen</em>, and the ledger row came back as "desfeito em 13/10/2026, 15:23" with the row reading <code>paço da liberdade</code> again; read back from the database, the entity carries its original name and no spelling row survives, which is the undo doing its job rather than the screen redrawing. The round's own artifacts were then removed — the scratch session and the ledger row it left — so the collection is as it was and no test write remains in its audit trail.</li> </ul> <h2 id="2026-10-13-the-merge-card-fed-by-the-type-ahead-issue-87">2026-10-13 — the merge card fed by the type-ahead (issue #87)<a class="headerlink" href="#2026-10-13-the-merge-card-fed-by-the-type-ahead-issue-87" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>e0c0720..bbd0189</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <em>Entidades — <code>/entidades/lista</code></em> now says the merge has <strong>three doors</strong> — the selection on Relevância, a pair on Similaridade and the <strong>Mesclar pelo nome</strong> card above the tabs — and why the third exists: the other two are list-driven, relevance is ordered by weight and capped, and similarity is trigram, the one measure that fails on the pair the archivist is most sure about. It names the card's three decisions: the type-ahead with the API's own two-character floor, each name's weight shown so the canonical is chosen with the numbers in view, and a list of its own that does not consume the relevance checkboxes.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation of the same.</li> <li><strong>Why the round exists:</strong> the screen had two ways into the merge panel and the archivist controls neither list, so the pair they already know is one entity was the pair the screen was least likely to show them. The freshness report named <code>guides/curate.md</code> for the SPA commit.</li> <li><strong>What the round measured.</strong> Measured: 1 528 tests passed, <code>basedpyright</code> 0 errors, the SPA's <code>tsc</code>, ESLint and Vite build green, and <code>mkdocs build --strict</code> clean. The acceptance pair was measured against the reference collection before it was rendered: <code>smu</code> (entity 45) carries <strong>68</strong> descriptions and <code>smcs secretaria municipal de comunicação social</code> (entity 349) carries <strong>1</strong>, so the relevance ranking — capped at fifty rows, whose lightest entry carries two — can never show both; and <code>similarity('smu', 'smcs secretaria municipal de comunicação social')</code> is <strong>0.045</strong>, far under any threshold the similarity tab accepts. Neither list reaches the pair; the card does. <code>testing/unit/curator/test_entities_merge_doors.py</code> is the gate for the shape: it fails when the write gains a second call site, when a door stops rendering <code><MergePanel</code>, or when the card starts calling the write, the preview or <code>navigate</code> itself. It is a <strong>textual</strong> gate and says so — the SPA has no test runner of its own.</li> <li><strong>What the round looked at.</strong> The screen was <strong>rendered</strong> against the reference collection: the built SPA served by the API, a scratch session created for the round and deleted after, the headless shell driven over CDP. Seen in the pixels: the card <strong>collapsed</strong> between the irreversibility <code>Notice</code> and the tabs, with the labelled "+ Mesclar pelo nome" button; and the card <strong>open</strong> after the two names were typed and picked one at a time, each row carrying its own weight ("smu · Local · 68 descrições", "smcs secretaria municipal de comunicação social · Local · 1 descrição"), with the panel below stating the <strong>real</strong> plan — "1 descrição(ões) passam a carregar smu · 1 vínculo(s) reescrito(s) · 1 entidade(s) absorvida(s)" — and its warning once, not twice. <strong>Not</strong> verified in pixels: an actual merge from this card. The round wrote nothing to the collection, and the write itself rests on the suite that pins the three doors to the one call site.</li> </ul> <h2 id="2026-10-13-the-spa-stops-re-declaring-the-contracts-query-shapes-no-page-changed">2026-10-13 — the SPA stops re-declaring the contract's query shapes (no page changed)<a class="headerlink" href="#2026-10-13-the-spa-stops-re-declaring-the-contracts-query-shapes-no-page-changed" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>e0c0720..HEAD</code></li> <li><strong>Pages:</strong> <strong>none</strong>, and that is the verdict this entry records. The change lives inside <code>apps/curator/src/api/</code>, and no page declares that path among its <code>sources:</code> — the freshness report named no page for it. What changed is a <strong>rule file</strong>, <code>.dsh/skills/curator-ui/SKILL.md</code>: it now states that a query shape is derived from the route and never typed out, with the measured failure beside it.</li> <li><strong>Why the round exists:</strong> the last change to this surface (#86) added <code>entity_id</code> to the contract and had to add it to the hand-written mirror by hand. Sizing that mirror found it had <strong>already</strong> lost a field, and that seventeen more shapes shared the pattern. A convention with no gate is what principle V is about, so the round closed the convention and left a test that fails on the next one.</li> <li><strong>What the round measured.</strong> <code>DocumentSearch</code> declared <strong>14</strong> keys against the route's <strong>15</strong>, missing <code>is_anomaly</code>, and nothing failed: <code>CollectionRoute.toRequest</code> spreads a wider <code>CollectionSearch</code> into it, the extra key survives the return type, and <code>openapi-fetch</code> sent it — so the screen filtered correctly through a field its own type denied. What that was hiding, measured with a session against the reference collection: <code>GET /api/v1/documents?is_anomaly=true</code> answers <strong>28</strong>, <code>?is_anomaly=false</code> <strong>73</strong> (28 + 73 = 101, the whole collection), and a misspelled <code>?is_anomalyy=true</code> or a nonsense <code>?non_sense=42</code> answers <strong>101 with HTTP 200 and no warning</strong> — an unknown query parameter is ignored, not refused. A contract that renamed the filter would therefore have left the list unfiltered with a chip on the screen saying it was filtered, and <code>tsc</code>, ESLint, the build and the suite green. <strong>19</strong> shapes derive from their route now (three of them named), and the ten call sites that build a one-key value inline — <code>query: { only_active: true }</code>, <code>query: { term, limit }</code> — are measured and left alone: they are values, not declared shapes, and deriving them would change the client's own signature and every caller. Measured: 1 528 tests passed (four of them the new gate), <code>tsc</code>, ESLint and the Vite build green. The derivation needed <strong>no</strong> reinterpretation — the generated type already spells an omitted filter <code>campo?: T | null</code> — and an earlier <code>Exclude<T[K], null></code> mapping was measured to be both unnecessary and wrong (it rejected two callers that legitimately hold <code>null</code>).</li> <li><strong>What the round looked at.</strong> The screen was <strong>rendered</strong>: <code>/acervo/lista?is_anomaly=true</code> against the reference collection, with a scratch session created for the round and deleted after, the headless shell driven over CDP. Seen in the pixels: the header reading <strong>"28 descrições"</strong> with the chip <strong>"Somente anomalias ✕"</strong> and every row carrying its <code>anomalia</code> badge — the same 28 the route answers, now filtering through a field the type declares instead of one it denied.</li> </ul> <h2 id="2026-10-13-the-stopword-purge-stops-being-the-write-with-no-way-back-36">2026-10-13 — the stopword purge stops being the write with no way back (#36)<a class="headerlink" href="#2026-10-13-the-stopword-purge-stops-being-the-write-with-no-way-back-36" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>f0ba7c2..HEAD</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — updated (the purge moved out of "what has no way back", the Stopwords section describes the ledger and the undo, and the reversibility table says yes)</li> <li><code>guides/curate.pt.md</code> — updated (the translation of the same three passages)</li> <li><code>guides/data-model.md</code> — updated (<code>archive_stopword_purge_log</code> joined the ledger table and the list of tables that carry an author pair)</li> <li><code>.dsh/skills/taxonomy/SKILL.md</code> — updated (the rule file said the purge "has no undo"; it now states the plan/apply/undo, the <code>synonym_names</code> snapshot and the ban that the undo must not lift)</li> <li><strong>Why the round exists:</strong> the decision on #36 was a ledger, scoped to destructive writes — and the page had been describing the exception as if it were the rule. The docs were not stale by drift; they were stale because the decision changed what is true.</li> <li><strong>What the round measured.</strong> The round trip through the routes, against <code>scrinalia_zeroed</code>: a scratch tag <code>1924</code> carrying <strong>2</strong> documents, <code>POST /tags/stopwords/purge/preview</code> answering <code>reversible=true, total_tags=1, total_documents=2</code>, the purge answering <code>tags_deleted=1, purge_ids=[1]</code>, and <code>DELETE /tags/stopwords/purge-log/1</code> putting the row and both links back while <code>domain_stopwords</code> kept <code>1924 | TAG</code>. The scratch tag, the ledger row and the scratch session were deleted after the round. Measured: <strong>1540</strong> tests passed, <code>ruff check</code>/<code>format --check</code> and <code>basedpyright</code> (0 errors) green, <code>alembic check</code> reporting no drift, the contract regenerated and stable, and <code>tsc</code>, ESLint and the Vite build green.</li> <li><strong>What the round looked at.</strong> The screen was <strong>rendered</strong>: <code>/assuntos/tags?aba=stopwords</code> with the scratch session, the headless shell driven over CDP. Seen in the pixels: the notice reading <strong>"Como o merge, ela guarda um ledger"</strong>, the card <strong>"Ledger das purgas"</strong> with the row <strong>"1924 · 2 doc(s) · por Cassio"</strong> and its <strong>desfazer</strong> button, and — after clicking it — the same row showing <strong>"restaurada em 13/10/2026, 18:13"</strong>. <strong>Not</strong> looked at: a purge over a real stopword of the collection (none of the 88 matched a tag, which is why the scratch tag existed).</li> </ul> <h2 id="2026-10-13-the-api-enforces-the-temporary-password-79">2026-10-13 — the API enforces the temporary password (#79)<a class="headerlink" href="#2026-10-13-the-api-enforces-the-temporary-password-79" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>bbd0189..feed903</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — updated (the sign-in section now says the API refuses every operation but the change, and names the three it still reaches, instead of leaving the refusal to the shell)</li> <li><code>guides/curate.pt.md</code> — updated (the translation of the same paragraph)</li> <li><strong>Why the round exists:</strong> the section described the temporary password as the screen's promise — "the account replaces it at the next sign-in" — while the guard read nothing, so a script holding the password the CLI printed kept the account's whole role. The decision that the <strong>API</strong>, not the screen, owns the refusal changed what is true on a page that documents the guard (<code>src/scrinalia/api/security.py</code> is in its <code>sources:</code>), which is why this is a <code>updated</code> and not a <code>no-change</code>.</li> <li><strong>What the round measured.</strong> The route through HTTP, against the test database: an account with <code>must_change_password=True</code> signed in, <code>GET /api/v1/curation/inbox</code> answering <strong>403</strong> with <code>error_code: "PasswordChangeRequiredException"</code> and <code>code: "PASSWORD_CHANGE_REQUIRED"</code>, <code>GET /auth/me</code> and <code>POST /auth/logout</code> answering <strong>200</strong>, and the same inbox answering <strong>200</strong> after <code>POST /auth/password</code>. Before the fix the same inbox answered <strong>200</strong> with the flag set — the defect, measured in this checkout before the guard changed. Measured: <strong>1550</strong> tests passed, <code>ruff check</code>/<code>format --check</code> and <code>basedpyright</code> (0 errors) green, and the contract regenerated with one additive enum member (<code>PASSWORD_CHANGE_REQUIRED</code>) and the committed client rebuilt from it.</li> <li><strong>What the round looked at.</strong> No screen was <strong>rendered</strong>, and none needed to be: no SPA file changed, the forced screen already reads the flag from <code>/auth/me</code> and calls <code>/auth/password</code>, and this change moves the enforcement behind that screen. <strong>Not</strong> looked at in a browser; the evidence is the integration test over HTTP, the unit guard test and the regenerated contract.</li> </ul> <h2 id="2026-10-13-the-scrape-and-the-transform-can-be-bounded-per-run-47-48">2026-10-13 — the scrape and the transform can be bounded per run (#47, #48)<a class="headerlink" href="#2026-10-13-the-scrape-and-the-transform-can-be-bounded-per-run-47-48" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>feed903..84bb5f0</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — updated (the pipeline table said "Nine workers, in pipeline order" and started at <code>transfer</code>; the page now names the two stages in front of it and documents their host commands, their flags, how a bounded run resumes by itself, and that a forced one resumes only with <code>--after</code>)</li> <li><code>guides/operate.pt.md</code> — updated (the translation of the same two pieces)</li> <li><code>guides/install.md</code> — updated (<code>ACERVO_SOURCE</code> also selects the origin's ports now, and <code>--origin</code> overrides it for one run, which is that row's business)</li> <li><code>guides/install.pt.md</code> — updated (the translation of that row)</li> <li><strong>Why the round exists:</strong> the report named <code>guides/install.md</code>, whose <code>sources:</code> include <code>ingestion/sources.py</code>, because that module gained the origin's port registry. <code>operate.md</code> was <strong>not</strong> reported stale — it declared neither ingestion nor staging — and that was the gap: the two stages that produce everything the archive reads had no page, and this change turns them into a batched operation an operator has to be able to run.</li> <li><strong>What the round measured.</strong> Full suite <strong>1574 passed</strong> (<code>uv run pytest</code>, with <code>TEST_DATABASE_URL</code> pointed at the test database on 5434), <code>ruff check</code> and <code>ruff format --check</code> green, <code>basedpyright</code> <strong>0 errors</strong>, <code>alembic check</code> reporting no new upgrade operations (no schema change), <code>mkdocs build --strict</code> green. The batched run was measured on a <strong>throwaway database</strong> — created on the test server, migrated with <code>alembic upgrade head</code>, seeded through the repositories and dropped afterwards — because the reference collection cannot reproduce the issue's "queue of thousands": measured there, <code>scraping_queue</code> holds <strong>100</strong> rows, all <code>DONE</code>, with <strong>100</strong> <code>raw_data</code>, <strong>100</strong> <code>staging_documents</code> and <strong>101</strong> <code>archive_documents</code>, and <strong>nothing pending to transform</strong>. On the scratch database, with a 250-row queue and a port double in place of the site: <code>--limit 100</code> took <strong>100</strong> and left <strong>150</strong>, then <strong>100</strong> and left <strong>50</strong>, then <strong>50</strong> and left <strong>0</strong>; the transform bounded at 100 left staging at 100, 200 and 250 rows with 150, 50 and 0 pending; and the forced run bounded by the cursor walked windows of 100, 100 and 50, ending with 0 left.</li> <li><strong>What the round looked at.</strong> No screen was <strong>rendered</strong>, and none needed to be: no SPA file changed. The commands were exercised — the parsers by the unit tests, the workers by the integration tests over the test database — but <strong>no scrape against the origin site was run</strong>, and the numeric evidence above is synthetic in that sense. That is the gap this entry declares.</li> </ul> <h2 id="2026-10-13-the-ner-purge-stops-being-the-write-with-no-way-back-94">2026-10-13 — the NER purge stops being the write with no way back (#94)<a class="headerlink" href="#2026-10-13-the-ner-purge-stops-being-the-write-with-no-way-back-94" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>84bb5f0..95796c3</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — updated (the NER veto no longer claims to reach backwards: it bans and deletes nothing; the retroactive half is <strong>Expurgar do passado</strong>, with its preview, its <code>archive_ner_purge_log</code> and its undo, and the screen's table now says both halves are reversible; the paragraph that put the veto under "What has no way back" is gone)</li> <li><code>guides/curate.pt.md</code> — updated (the translation of the same section)</li> <li><code>guides/data-model.md</code> — updated (<code>archive_ner_purge_log</code> joins the ledger table, the conflict ledger row names the spellings it now restores, and <code>domain_ner_exclusions</code> says the row itself deletes nothing)</li> <li><code>guides/data-model.pt.md</code> — updated (the translation of those rows)</li> <li><strong>Why the round exists:</strong> the report named <code>guides/curate.md</code> and <code>guides/data-model.md</code>, and the API delta was the four purge routes. The change moves a destructive write out of a reversible toggle, so every sentence that described the old coupling had to be re-read, not just the routes.</li> <li><strong>What the round measured.</strong> Full suite <strong>1591 passed</strong> (<code>uv run pytest</code>, with <code>TEST_DATABASE_URL</code> pointed at the test database on 5434), <code>ruff check</code> and <code>ruff format --check</code> green, <code>basedpyright</code> <strong>0 errors</strong>, <code>alembic check</code> reporting no new upgrade operations after the new migration was applied, <code>bun run --cwd apps/curator typecheck lint build</code> green, and <code>mkdocs build --strict</code> green. The five new integration tests pin the plan/apply/ledger round trip, the spelling restore on both the NER purge and the conflict undo, the 409 on a second undo and the veto surviving the undo.</li> <li><strong>What the round looked at.</strong> <code>/entidades/excecoes</code> was <strong>rendered</strong> on the dev stack (the API on <code>scrinalia_zeroed</code>, the built SPA served by the API, a scratch session created for the round and deleted after, the headless shell driven over CDP). Seen in the pixels: the veto card stating "Vetar não exclui nada", the <strong>Expurgar do passado</strong> card with its preview button, the <strong>Ledger dos expurgos</strong> card, the preview reporting the entities and their links, the purge writing a ledger row, and the undo marking that row "restaurada". The non-empty preview needed a matching entity, so one <strong>temporary</strong> orphan entity (<code>misto</code>, <code>ORG</code>) was inserted into the zeroed database and removed afterwards, together with the ledger row and the scratch session; the round trip was driven through the screen. That is the one synthetic input this entry declares.</li> </ul> <h2 id="2026-10-13-the-tag-axis-is-the-subject-axis-83-84">2026-10-13 — the tag axis is the subject axis (#83, #84)<a class="headerlink" href="#2026-10-13-the-tag-axis-is-the-subject-axis-83-84" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>95796c3..32d19f1</code>. The range ends at the second of the two commits that carry the change (the API/domain one is <code>5055312</code>, the curator one is <code>32d19f1</code>), so the floor closes the whole change. <code>guides/operate.md</code> declares <code>models/enums.py</code>, which moved, so it was read and not skipped.</li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <em>Classificar</em> tab has <strong>two</strong> destinations now — a drawer, or not-a-subject — instead of three. The <code>FACET</code> row leaves the state table, the row's evidence loses the facet, and the producer/place/person cases move to the <strong>entity axis</strong> (a type <code>LOC</code>, <code>PER</code>, <code>ORG</code> that search and the public projection read) and to the tag × entity conflict screen; the <code>PUT</code>/<code>DELETE /api/v1/taxonomy/tags/{tag_id}/facets/{facet_type}</code> routes are gone. The <em>Não é assunto</em> section stops promising that "only the subject classification stops guessing": confirming <strong>also bans the spelling at <code>TAG</code> scope</strong>, the ban deletes nothing, and the purge stays a deliberate act behind its preview and ledger. It documents the new read-only <code>POST /api/v1/taxonomy/tags/subject-exclusions/preview</code> (<code>AUTHENTICATED</code>) with its <code>applicable</code>/<code>blocked</code>, the refusal of a term whose tag is already in a drawer (<code>SUBJECT_EXCLUSIONS_BLOCKED</code> / <code>SUBJECT_EXCLUSIONS_PARTIAL</code>, <code>blocked</code>, <code>created</code>, <code>banned</code>), and the undo's <code>removed</code> <strong>and</strong> <code>unbanned</code> lifting only the ban this decision planted. The Stopwords tab and its purge preview now carry the ban's <code>source</code> (<code>HUMAN</code> / <code>CONFLICT</code> / <code>EXCLUSION</code>).</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong> (the translation of the same three places).</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: <code>archive_tag_facets</code> leaves the subject table (a tag is a drawer or not-a-subject; what a place, a producer or a person <em>is</em> belongs to the entity axis), <code>domain_stopwords</code> gains <code>source</code> and <code>domain_subject_exclusions</code> gains <code>ban_created</code>, the bidirectional-governance section separates <code>save_stopwords</code> (moves the scope) from <code>ensure_tag_ban</code> (widens and never narrows), and the ledger note says <code>ban_created</code> belongs to both writers of a ban.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong> (the translation of the same).</li> <li><code>README.md</code> — <strong>updated</strong>: the front door still said a provenance-or-geography tag carries an <code>archive_tag_facets</code> row; it now names the entity axis and the ban. (Not a sourced page — the freshness report cannot flag it, and it was found by reading the change.)</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the <code>macro-category</code> worker's unit and its pending rule (<code>is_subject_candidate</code> + the exclusions) never read the tag catalogue's states, and the diagnostic SQL counts <code>macro_category_id IS NULL</code>, which the dropped table never entered. The page declares <code>models/enums.py</code>, which moved, so it was read and not skipped.</li> <li><code>adr/index.md</code> — the implementation change already links ADR 0013; the coverage gate's ADR check is green. <code>adr/0008-...md</code> was <strong>updated</strong> in the same commit: it still said <code>is_place_term</code> "claims it for the PLACE facet" and that a place kind "claims the PLACE facet", which ADR 0013 makes false. The distinction ADR 0008 draws is kept and the destination moves to the entity axis, recorded there as an <code>Amended by ADR 0013</code> blockquote and named in ADR 0013's <code>Amends:</code>.</li> <li><strong>Why the round exists:</strong> <code>freshness.py</code> printed <strong>"Every page is fresh"</strong> because the hook reads commits and the whole change is uncommitted — the report named nothing, so the triage was driven by the working-tree diff. That diff moves <code>curate.md</code>'s declared sources (<code>api/controllers/**</code>, <code>services/tag_service.py</code>, <code>repository/tag_repo.py</code>, <code>models/enums.py</code>, <code>packages/api-contract/openapi.json</code>, and the SPA <code>routes/**</code>) and <code>data-model.md</code>'s (<code>models/**</code>, <code>repository/tag_repo.py</code>). The API delta is two routes removed and one added.</li> <li><strong>What the round measured.</strong> <code>uv run pytest testing/unit/docs -q</code> — <strong>28 passed</strong>; <code>testing/unit/docs/test_documentation_coverage.py</code> — <strong>7 passed</strong> (screens, tables, settings, workers, ADRs and the API-resource check); <code>uv run mkdocs build --strict</code> green. The full Python suite and the SPA build belong to the round that lands the change and were not re-run here.</li> <li><strong>What the round looked at.</strong> The screens were <strong>rendered</strong>, not inferred. The built SPA was served by the API (<code>create_app()</code> mounts <code>apps/curator/dist</code>) against the reference collection, with a scratch session created for the round and deleted after, and the headless shell drove three routes over CDP. Seen in the pixels: <em>Classificar</em> (<code>/assuntos/tags?aba=classificar</code>) with the four chips — <code>Todas</code>, <code>Sem decisão</code>, <code>Não é assunto</code>, <code>Confiança baixa</code>, <strong>no <code>FACET</code></strong> — the row's <code>Decidir</code> column reduced to the category select with no producer/place buttons, and the header reading "204 tag(s) neste estado", which is the same 204 the SQL count of undecided tags reports. <em>Não é assunto</em> (<code>/assuntos/excecoes</code>) with "4 candidatos · 4 são lugares (o eixo de entidades os carrega)", the <code>é lugar</code> badge and the new hint "quem carrega o lugar é o eixo de entidades (LOC)", the disclosure sentence naming the ban, and "139 termos fora do eixo de assunto" — the measured population. <em>Stopwords</em> (<code>/assuntos/tags?aba=stopwords</code>) with the origin badge on the terms the backfill classified <code>CONFLICT</code> (<code>veio de um conflito tag × entidade</code>) and <strong>no badge</strong> on the <code>HUMAN</code> ones (<code>1924</code>, <code>1926</code>), which is the 83/5 split the migration produced. The <strong>blocked</strong> branch was rendered too, by putting one candidate tag (<code>ruas</code>, id 131) into <code>Mobilidade e Transporte</code> and shooting again: the row gained the <code>está em Mobilidade e Transporte</code> badge and its button went disabled; the mutation was reverted immediately and the pending count returned to 204. <strong>Not</strong> verified in pixels: the write itself from the screen (the refusal and the ban are pinned by the integration suite, which drives the real routes and the real <code>ban_created</code>/<code>source</code> columns).</li> </ul> <h2 id="2026-10-13-the-thumbnail-worker-runs-against-a-local-endpoint-33">2026-10-13 — the thumbnail worker runs against a local endpoint (#33)<a class="headerlink" href="#2026-10-13-the-thumbnail-worker-runs-against-a-local-endpoint-33" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>480fd36..153afb2</code> — the merge that carried the previous round, then this change. The change was <strong>uncommitted during the triage</strong>, so <code>freshness.py</code> printed "Every page is fresh"; the triage was driven by the working-tree diff, as the previous entry's was.</li> <li><strong>Pages:</strong></li> <li><code>guides/install.md</code> — <strong>updated</strong>: the settings table gains <code>S3_AUTO_CREATE_BUCKET</code> (default <code>false</code>) and the <code>S3_BUCKET_NAME</code> row stops saying the bucket "must already exist" without qualification; the <em>Object storage is not shipped</em> section now says the worker confirms the bucket once, before the queue, creates it only with the flag on, and that SeaweedFS answers <code>AccessDenied</code> to an upload into a bucket that does not exist — which is why the confirmation exists. It also states that <code>GET /system/health</code> reports the distinction and never creates anything.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong> (the translation of both places).</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the runner's flag table gains <code>--limit N</code> (a cap on the units of one run, honored by the workers that declare <code>limit</code> and refused by the others) and now says that <code>--batch</code> is <strong>logged</strong> when the worker does not declare <code>db_batch_size</code>; the <code>thumbnail</code> bullet names the bucket confirmation and the cap.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong> (the translation of the same).</li> <li><strong>Why the round exists:</strong> the change moves <code>core/config.py</code> (the install page's declared source) and <code>core/storage.py</code>, <code>api/system_health.py</code>, <code>workers/runner.py</code> and <code>workers/worker_*.py</code> (the operate page's), so both guides were read and both needed a text.</li> <li><strong>What the round measured.</strong> Full suite <strong>1608 passed</strong> (<code>TEST_DATABASE_URL</code> pointed at the test database on 5434), <code>ruff check</code>/<code>ruff format --check</code> green, <code>basedpyright</code> <strong>0 errors</strong>, <code>alembic check</code> reporting no new upgrade operations, <code>mkdocs build --strict</code> green. Against the local SeaweedFS and the reference collection the worker went from <strong>0/97</strong> to <strong>97/97</strong> descriptions with <code>storage_thumbnail_uri</code>, <strong>97</strong> objects under <code>thumbnails/</code>, <strong>0</strong> <code>thumbnail_failed</code>, and a <code>SUCCESS</code> row in <code>archive_worker_runs</code> (id 14, <code>trigger=CLI</code>, 55.5 s). The refusals were exercised: <code>--limit 2</code> processed exactly 2, <code>--limit 2</code> on <code>ner</code> was refused with the list of workers that declare it, and <code>--batch 2</code> on <code>thumbnail</code> logged that the value was ignored. <code>S3_AUTO_CREATE_BUCKET=true</code> created a scratch bucket on the endpoint (deleted after the measurement), and the flag-off path refused with the bucket and the <code>aws s3 mb</code> command named.</li> <li><strong>What the round looked at.</strong> No screen was <strong>rendered</strong>, and none needed to be: no SPA file changed. The storage refusals are pinned by unit tests and were also exercised by hand against the real endpoint. <strong>Not</strong> verified in pixels: the <em>Diagnóstico</em> storage card, which the probe's two branches feed — those branches are pinned by <code>testing/unit/api/test_system_health.py</code>.</li> <li><strong>Divergence the round surfaced.</strong> The CLI could not write its own ledger row: <code>archive_worker_runs</code> carries a foreign key into <code>auth_users</code>, and a process that imported only the archive models failed to flush the insert with <code>NoReferencedTableError</code>; the ledger swallows a bookkeeping failure by design, so a CLI run left no line while the panel's runs did. Fixed in the same change: <code>scrinalia.metadata.load_all_models</code> is the one definition of the model packages, and <code>migrations/env.py</code> and <code>WorkerRunLedger</code> both call it.</li> </ul> <h2 id="2026-10-13-the-pre-release-pass-for-120">2026-10-13 — the pre-release pass for 1.2.0<a class="headerlink" href="#2026-10-13-the-pre-release-pass-for-120" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>153afb2..HEAD</code>. The round was <strong>uncommitted during the triage</strong>, so <code>freshness.py</code> printed "Every page is fresh"; the triage was driven by the working-tree diff, as the two previous entries' were. Only three commits followed the ledger's floor (<code>153afb2</code>) — the entry that closed the thumbnail round and its test — so no page's <code>sources:</code> had moved when the reading began, and none of the drift below was a source delta either.</li> <li><strong>Pages:</strong></li> <li><code>README.md</code> — <strong>updated</strong>: the status line moves to <code>1.2.0</code>; the <em>no-undo</em> paragraph, which named three writes that had since become reversible; the runner snippet gains <code>--limit N</code>; <code>/sistema/workers</code> is read-only for configuration since #53; the unauthenticated list gains the first-run routes; the <code>worker_*.py</code> claim gains its exception.</li> <li><code>docs/index.md</code> and <code>docs/index.pt.md</code> — <strong>updated</strong>: "Scrinalia is at 1.1" → "at 1.2".</li> <li><code>guides/install.md</code> and <code>guides/install.pt.md</code> — <strong>updated</strong>: the two <code>Sistema › …</code> breadcrumbs, which name a group the shell does not have; "both doors close forever", which is true of the first-run route and not of the CLI; and the page's <code>sources:</code> gains the SPA navigation files it states facts about (<code>AppShell.tsx</code>, <code>lib/screens.ts</code>, <code>lib/settings.ts</code>), which the freshness report could never flag before.</li> <li><code>guides/operate.md</code> and <code>guides/operate.pt.md</code> — <strong>updated</strong>: the worked <code>conflict-judge --option host=…</code> example, which the runner <strong>refuses</strong> (measured: <code>InvalidWorkerSettingsError</code>); the <code>cleaning</code> unit cell; the claim that the CLI formats the duplicate-run refusal the way the API does; the <code>reset-password --password</code> comment.</li> <li><code>guides/curate.md</code> and <code>guides/curate.pt.md</code> — <strong>updated</strong>: the "Curadoria" nav group, which does not exist; "Four decisions" under five bullets; the ungoverned-worker count; the subject-exclusion preview no screen calls; the <em>Trechos</em> row, which called the removal reversible.</li> <li><code>guides/data-model.md</code> and <code>guides/data-model.pt.md</code> — <strong>updated</strong>: <code>archive_entity_merge_log</code> joins the ledger table; the author pair gains the two new ledgers; the ban-undo guard, which was scoped to two writers and belongs to one; four stamp keys and the value-keyed count; <code>NOT_PUBLIC_FIELDS</code>; <code>archive_tags</code> under the retire-with-<code>is_active</code> rule; "both merges".</li> <li><code>CHANGELOG.md</code> — <strong>updated</strong>: <code>## [1.2.0] - 2026-10-13</code> replaces <code>## [Unreleased]</code>, which carried <strong>two</strong> entries for ninety-one commits. The cycle's most consequential change — the tag axis becoming the subject axis (ADR 0013) and its route removals — was in neither.</li> <li><code>mkdocs.yml</code> — <strong>updated</strong>: ADR 0013 joins the site nav.</li> <li><strong>Why the round exists:</strong> milestone 1.2 is complete, so this is the release preparation <code>CONTRIBUTING.md</code> asks for in one pull request — the version, the changelog, and the claims that drifted while no gate looked at them.</li> <li><strong>How the pages were read.</strong> The freshness report names a page only when a declared source moved, and it cannot see a page with no <code>sources:</code> at all, nor a <strong>number or a sentence</strong> inside a page whose sources stayed put. This round therefore read every claim of the five pages against the code, one page per reviewer: the report was <strong>silent</strong> and every defect below was still there. Most predate the cycle; two were introduced by it — the entity rename landed without bumping "Four decisions", and the <em>Trechos</em> removal had never been reversible.</li> <li><strong>The gap no gate covered, now gated.</strong> <code>docs/adr/0013-the-tag-axis-is-the-subject-axis.md</code> existed, was linked from <code>adr/index.md</code> and passed the ADR coverage check, and was <strong>absent from the site nav</strong>: <code>mkdocs build --strict</code> stays green because an unlisted page is an <code>INFO</code>, not a warning, so the ADR built and no menu reached it. The nav lists it now, and <code>testing/unit/docs/test_documentation_coverage.py</code> gained <code>test_every_adr_is_in_the_site_nav</code>, which reads <code>mkdocs.yml</code> as text — reachability is the nav, so the nav is what the gate reads.</li> <li><strong>A code defect the reading surfaced, and its gate.</strong> <code>NOT_PUBLIC_FIELDS</code> listed <code>original_thumbnail_url</code>, which is <strong>not</strong> a field of <code>DocumentSummary</code> (it lives on <code>ArchiveDocumentDTO</code>), so the declaration withheld a field the internal summary never carried and the comment justified it as "the source link, superseded by <code>storage_thumbnail_uri</code>". The entry is gone, and <code>test_every_declared_non_public_field_exists</code> fails on the next name that is not a field: the partition test reads <code>internal − published − NOT_PUBLIC_FIELDS</code>, so a dead entry was invisible to it.</li> <li><strong>Two defects left standing on purpose, both now documented.</strong> The conflict resolution's undo guards on <code>ban_created</code> but not on the ban's <code>source</code>, so it can lift a ban a person re-planted on the Stopwords screen — a real gap in <code>EntityRepository._lift_conflict_ban</code>, recorded in the data-model page rather than fixed here, because changing an undo's reach belongs to its own change. And <code>WorkerStamp("thumbnail")</code> is declared and never written, <code>worker_thumbnail</code>'s queue being led by <code>storage_thumbnail_uri</code>; the page says so now.</li> <li><strong>What the round measured.</strong> Full suite <strong>1610 passed</strong> (<code>TEST_DATABASE_URL</code> pointed at the test database on <strong>5434</strong> — not 5433, which another project holds on this host; the compose file publishes <code>${TEST_DB_PORT:-5433}</code>, so 5433 would have talked to the wrong PostgreSQL), <code>ruff check</code> and <code>ruff format --check</code> green, <code>basedpyright</code> <strong>0 errors</strong>, <code>mkdocs build --strict</code> green, and the CI contract check replicated by hand: <code>uv run python -m scrinalia.api.dump_openapi</code> is <strong>byte-identical</strong> to the committed contract at <code>info.version</code> <code>1.2.0</code>. <code>uv.lock</code> and <code>bun.lock</code> carry the number and were regenerated, and <code>testing/unit/api/test_openapi_metadata.py</code> pins the served document, the installed distribution, <code>pyproject.toml</code> and the committed contract together.</li> <li><strong>What the round looked at.</strong> No screen was <strong>rendered</strong>, and none needed to be: no SPA file changed. The UI statements corrected here are navigation labels and counts, read from <code>AppShell.tsx</code>, <code>lib/screens.ts</code> and <code>lib/settings.ts</code> — <strong>not</strong> observed in pixels, which is what a screen change would require.</li> <li><strong>Not verified, and said so.</strong> The reviews did not re-measure the page's hardware figures (disk, RAM, wheel sizes), the Bun argument-forwarding note, or the contents of the <code>ACERVO_LANGUAGE</code> profile, and <code>install.pt.md</code> was checked by grep rather than read end to end for structural drift. The working tree also moved under the reviewers while the fixes landed, so each verdict above is against the tree at the moment it was read.</li> <li><strong>Not carried, on purpose:</strong> the ADRs. <code>0010</code> still records "24 screens in <code>router.tsx</code>", <code>0012</code> still quotes the index's old "at 1.0", and <code>0011</code> still says "cycle 1.1": an ADR records the reasoning at the time, and none of those numbers is a rule the code has to satisfy. <code>src/scrinalia/api/openapi.py</code>'s docstring keeps "with <code>pyproject.toml</code> at 1.1.0" — that is the date of a <strong>measurement</strong>, and changing the number would falsify the record rather than update it.</li> </ul> <h2 id="2026-10-14-the-held-majors-adopted-on-purpose-40">2026-10-14 — the held majors adopted on purpose (#40)<a class="headerlink" href="#2026-10-14-the-held-majors-adopted-on-purpose-40" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>3c108cd..0ec13bb</code></li> <li><strong>Pages:</strong> three were reported stale and all three hold.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: the <code>DB_*</code> table, the "<code>DATABASE_URL</code> is a derived property" paragraph and the <code>docker-compose.test.yml</code> section still describe the code. The source this page declares that moved (<code>pyproject.toml</code>) is not quoted in its text, and the driver the DSN now names (<code>postgresql+psycopg2</code>) is not a setting an installer chooses.</li> <li><code>guides/data-model.md</code> — <strong>no-change</strong>: no table, column or index moved. <code>testing/conftest.py</code> gained a third thing it builds before <code>create_all</code> — the native enum types the models declare that the migration owns — and the page does not enumerate that list; it names the two SQL functions because a generated column depends on them.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: no worker's unit, queue predicate, idempotency stamp or governance rule changed. The two rewrites inside them are internal spellings that return the same rows — <code>SELECT DISTINCT ON (archive_worker_runs.worker_name)</code>, and a <code>Row</code> that no longer needs <code>Result.tuples()</code> — and the panel and queue-count tests that read them are green.</li> <li><strong>Why the round exists:</strong> <code>pyproject.toml</code> held two majors behind a comment (<code>pandas<3</code>, <code>sqlalchemy<2.1</code>), and adopting them changes the environment every installation and CI gets.</li> <li><strong>What the round measured.</strong> The driver: <code>postgresql+psycopg2</code> is spelled out in <code>core/config.py</code>, <code>testing/conftest.py</code> and the CI environment, so the default cannot move under the project again. SQLAlchemy 2.1.4: <code>Enum(..., create_type=False)</code> stopped being ignored — the PostgreSQL impl of <code>domain_stopwords.word_scope</code> comes out <code>create_type=False</code> on 2.1.4 and came out <code>True</code> on 2.0.54 — which cost the test schema <code>stopwords_scope</code>, <code>anomaly_type_enum</code> and <code>scrape_status_enum</code> and gave <strong>851 errors at setup</strong> (<code>type "stopwords_scope" does not exist</code>) until <code>conftest.py</code> created them from the models; nine <code>basedpyright</code> errors answered at their sites; <code>Result.tuples()</code> and the expression form of <code>distinct()</code> removed, the latter measured as <code>SELECT DISTINCT ON (archive_worker_runs.worker_name)</code>. pandas 3.0.6: the default string dtype is the new <code>str</code> and copy-on-write is on, neither of which this code meets (no frame is mutated in place, and the only frame read is BERTopic's topic info); <code>Series.__getitem__</code> now returns a union that includes a <code>Series</code>, and the two <code>int(row[...])</code> sites answer with a <code>cast</code>. Final: <strong>1610 passed</strong> on a fresh test database with <code>-W error::DeprecationWarning</code>, <code>basedpyright</code> <strong>0 errors</strong>, <code>ruff check</code> and <code>ruff format --check</code> green, <code>alembic check</code> reporting no new upgrade operations, and the committed contract byte-identical to <code>dump_openapi</code> by hand.</li> <li><strong>What the round looked at.</strong> No screen was <strong>rendered</strong>, and none needed to be: no file under <code>apps/curator/</code> changed.</li> <li><strong>Not verified, and said so.</strong> The real engines were not run — the suite mocks the registry, so <code>bertopic</code> was imported, never used to cluster — and the lock was resolved on this host, not on a GPU installation, which the install page already says is a different lock.</li> </ul> <h2 id="2026-10-14-the-test-schema-also-drops-what-it-creates-40">2026-10-14 — the test schema also drops what it creates (#40)<a class="headerlink" href="#2026-10-14-the-test-schema-also-drops-what-it-creates-40" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>0ec13bb..3227e3d</code></li> <li><strong>Pages:</strong> <code>guides/data-model.md</code> — <strong>no-change</strong> (the previous entry judged the create half of the same helper): CI's migration step, which runs after the suite against the same database, failed with <code>type "anomaly_type_enum" already exists</code>, because <code>drop_all</code> skips a <code>create_type=False</code> type. <code>testing/conftest.py</code> now drops them after <code>drop_all</code>, from one generator shared with the create, and no table, column or index moved.</li> <li><strong>What the round measured.</strong> The CI sequence replicated on a fresh test database: 1610 passed, <code>pg_type</code> and <code>pg_tables</code> empty after the teardown, <code>alembic upgrade head</code> applying every migration, and <code>alembic check</code> reporting no new upgrade operations.</li> </ul> <h2 id="2026-10-14-the-stamp-names-the-producer-41-70">2026-10-14 — the stamp names the producer (#41, #70)<a class="headerlink" href="#2026-10-14-the-stamp-names-the-producer-41-70" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>f240c71..52ed890</code></li> <li><strong>Pages:</strong> three were reported stale by <code>docs/_hooks/freshness.py</code> and all three changed.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the queue table and the identity paragraph (five of the nine stamps now carry the producer), the <code>--limit</code> row (six workers, not <code>thumbnail</code> alone), the <code>--option</code> coercion (the declared type, not just <code>true</code>/<code>false</code>), the reprocessing section (a new producer re-queues by itself; <code>force</code> reaches <code>ner</code> and <code>typology</code> and clears the <code>ERROR</code>), the new <em>Trying a configuration before saving it</em> section with the measured <code>0</code> → <code>4.465</code>, and the route table's preview row. The paragraph that recorded the old trap — "<code>embedding_v1</code> is the MD5 of the text, not the identity of the model, so changing <code>torch</code> does not put a single description back in the queue" — was the defect this round removes, and it now says what <code>?</code> does and does not answer.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the <code>execution_log</code> key table and the value-keyed paragraph (six keys, the identity, the two producers deliberately outside it).</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <code>Conferir impacto</code> button of <code>/configuracoes/workers</code>, and why the number clears when a field moves.</li> <li><strong>What the round measured.</strong> The forced-run defect, with a throwaway test (deleted) against the test database: <code>quality-validator</code> with <code>force=True</code> counts the same unit <em>after</em> it is stamped (<code>before=1 after=1</code>), and its loop had no cursor, so the documented command <code>runner quality-validator --option force=true</code> never ended. <code>ner</code> and <code>typology</code> advance only because the stamp leaves the queue (<code>before=1 after=0</code>), which is exactly the clause <code>force</code> drops. The identity itself: <code>describe_config</code> for <code>cpu_local</code> and <code>gpu_cloud</code> differs only in <code>device</code>, and the whole output is hashed on purpose. The suite: <strong>1660 passed, 1 skipped</strong> (the skip is the conflict-judge's counter-parity case, which has no queue); <code>basedpyright</code> <strong>0 errors</strong>; <code>ruff check</code> and <code>ruff format --check</code> green; <code>alembic check</code> reporting no new upgrade operations; the committed contract regenerated from <code>dump_openapi</code>.</li> <li><strong>What the round looked at.</strong> The settings screen <strong>was rendered</strong>: headless shell + CDP against the dev server and a scratch database (<code>scrinalia_render</code>, 4.842 documents) with a session row inserted and deleted for the round. With the collection stamped by the producer in the code, the default configuration answered <code>0 na fila com esta configuração (nada foi salvo).</code>; typing <code>{"confidence_threshold": 0.9}</code> into the options field and pressing <strong>Conferir impacto</strong> answered <code>4.465 na fila com esta configuração (nada foi salvo).</code> The screenshots are under <code>.render/</code> (gitignored), not in the repository.</li> <li><strong>Not verified, and said so.</strong> The real engines were not run — the suite mocks the registry, and no vector, entity or typology was produced by a model in this round. The queue numbers of the <em>real</em> collection were not measured; the <code>4.465</code> above is the scratch database the render used. The installed versions are read for <code>torch</code>, <code>transformers</code>, <code>sentence-transformers</code> and <code>spacy</code> only: the spaCy model package (<code>pt_core_news_lg</code>) and the digest of the Ollama model stay outside the identity, because the first lives in the language profile and the second would take a network call on the panel's path — both documented on the page and left as a follow-up, not as a silent gap.</li> <li><strong>Consequence to expect.</strong> Every unit stamped before this change — including a <code>DONE</code> — is pending again under the producer now configured, so the first run after upgrading re-reads the collection once. That is the fix working, and the preview is how to size it before paying it.</li> </ul> <h2 id="2026-10-17-the-bench-family-and-the-rule-axis-with-no-rule-to-score-114-115-116">2026-10-17 — the bench family, and the rule axis with no rule to score (#114, #115, #116)<a class="headerlink" href="#2026-10-17-the-bench-family-and-the-rule-axis-with-no-rule-to-score-114-115-116" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>52ed890..4436154</code></li> <li><strong>Pages:</strong> three were reported stale by <code>docs/_hooks/freshness.py</code>. All three are <strong>no-change</strong>, and the single commit that moved a declared source is <code>ef11773</code> — the refactor the validator bench needed, which named three functions and a separator instead of changing behaviour.</li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: nothing on a screen or a route moved. The rules screen still offers the same three <code>rule_kind</code>s with the same three writes, and the paragraph that says a <code>VALIDATE</code>/<code>LLM_CHECK</code> rule only flags and never rewrites still holds. The change to <code>models/enums.py</code> is a named constant plus two accessors for a format that already existed.</li> <li><code>guides/data-model.md</code> — <strong>no-change</strong>: no table, no column and no constraint changed. <code>anomaly_reasons</code> keeps its type and its <code>CODE:payload</code> content — the separator became <code>ANOMALY_REASON_SEPARATOR</code> in <code>models/enums.py</code> and the facet's SQL reads it instead of a literal <code>":"</code>, which is one definition where there were two. The <code>archive_cleaning_rules</code> row and the <code>rule_kind</code> paragraph are untouched.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the worker table, the queue predicates and the identity paragraph describe behaviour that did not move. <code>worker_quality_validator.py</code> gained <code>matched_rule_labels</code>, <code>llm_check_reason</code> and <code>rule_flag_label</code>, and <code>_compile_rules</code> became <code>compile_rules</code>; the worker marks the same documents with the same reasons and stamps the same producer.</li> <li><strong>What the round found.</strong> The validator bench needed "did this rule fire", which existed only inside <code>evaluate_document</code>, so the answer became a function the worker and the bench share — and the pin between them is an integration test that runs the real worker and asks the bench what fired, with a rule carrying an <code>anomaly_reason</code> rather than its name.</li> <li><strong>What the round measured.</strong> Against <code>memoriacuritibana</code> on 2026-10-16: the conflict judge agrees with the 49 human resolutions at <strong>0.918</strong> while a constant ENTITY answer scores 1.000 (Δ −0.082), and all four misses are the same direction at confidence 0.900 — high enough that the worker would have auto-resolved every one of them. The validator flags <strong>4,583 of 4,831 documents (94.9%)</strong>, of which <code>RULE_MATCH</code>/<code>LLM_SUSPECT</code> are <strong>zero</strong>: the catalogue holds one rule, inactive and <code>REWRITE</code>, so the per-rule axis has nothing to score and both benches refuse with that sentence instead of an empty table.</li> <li><strong>Not verified, and said so.</strong> <code>xlm_roberta_local</code> was never executed — the weights are not in this machine's read-only Hugging Face cache — so its preset is wired at the code level only. The <code>granite_local</code> conflict judge answered 500 to every prompt (<code>granite4.1:3b</code>, whose llama runner crashes on this Ollama build) and the bench counted 49 engine failures rather than scoring them; the engine defect that makes that possible is #122. No screen was rendered in this round: the work is a bench, two refactors and a ledger.</li> </ul> <h2 id="2026-10-17-the-typology-queue-is-gated-on-the-stamp-117">2026-10-17 — the typology queue is gated on the stamp (#117)<a class="headerlink" href="#2026-10-17-the-typology-queue-is-gated-on-the-stamp-117" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>4436154..22bfe62</code></li> <li><strong>Pages:</strong> one was reported stale by <code>docs/_hooks/freshness.py</code> and it <strong>changed</strong>.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the queue table's <code>typology</code> row no longer says "documents with no typology" (the clause is gone) and now names the label set as part of the stamp; the "catalogue changed" bullet gained the typology catalogue and the refusal that clears an answer; the <code>force</code> bullet no longer claims <code>typology</code> never reclassifies a document that already has one, which was the sentence the change falsified; and the paragraph that sizes a re-queue now says that <strong>every</strong> stamped description is pending once after this upgrade, not only the ones stamped by an older producer. The Portuguese page carries the same four edits.</li> <li><strong>What the round measured.</strong> Against <code>memoriacuritibana</code> on 2026-10-17, and the numbers are the argument: <code>count_pending</code> answered <strong>4,205</strong> under floor 0.40, floor 0.90, two column sets and two presets — 4,813 ai-writable documents minus the 608 carrying a typology, exactly — so no change the identity signs could reach a single classified document. After the change the same four configurations answer <strong>4,813</strong>. The 608 split into 135 with a typology stamp and <strong>473 with none</strong>; the 4,223 without a typology split into 4,188 never seen and 35 seen and refused.</li> <li><strong>What the round checked.</strong> The pair is pinned by integration tests that read the real queue through the worker's own <code>pending_conditions</code>: adding, renaming and retiring a typology each re-queue the affected documents, an unchanged catalogue re-queues nothing, a description the current producer settled stays out while the 0.55 floor reaches the one the 0.40 floor settled, and a refusal clears the answer another producer wrote. Four unit tests pin the identity itself: order-independent, different for an added, renamed, retired or replaced drawer, and still separating the floor and the columns.</li> <li><strong>Not verified, and said so.</strong> The re-classification of the 4,813 was <strong>not executed</strong> — the queue was counted, not paid: at the measured ~2 s per document on the title alone that is about 2.7 hours of CPU, and no model was run against the real collection in this round. So "the 473 legacy answers are re-read" is a projection from the predicate, not an observation, and the first run after deploying is where it becomes one. The clearing on refusal is pinned against a fixture engine in the test database, not against the real classifier: <code>gemma</code>/<code>mDeBERTa</code> weights are not exercised here.</li> </ul> <h2 id="2026-10-17-the-typology-bench-compares-label-formats-112">2026-10-17 — the typology bench compares label formats (#112)<a class="headerlink" href="#2026-10-17-the-typology-bench-compares-label-formats-112" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>22bfe62..a554cbe</code></li> <li><strong>Pages:</strong> none. <code>docs/_hooks/freshness.py</code> reported every page fresh: the round touched <code>testing/evaluation/typology_quality.py</code>, its tests and the evaluation README, and no guide declares a bench as a source. The entry exists to record the verdict and to advance the floor, not because a page was at risk.</li> <li><strong>What the round measured.</strong> The three formats on 111 of the 112 labelled rows, CPU, at the shipping configuration <code>deberta_typology:cpu_local:original_title:0.40</code>: <code>bare</code> agrees <strong>0.775</strong> with macro-F1 0.484 and 5.4% abstention; <code>trata_de</code> <strong>0.784</strong> with 0.478 and 7.2%; <code>sentence</code> <strong>0.604</strong> with 0.414 and <strong>29.7%</strong>. The plans are where <code>sentence</code> breaks — recall 0.956 → 0.511, with 20 of them abstaining — which is #30's lost entailment reappearing exactly where <code>classifier_labels</code> warns it does.</li> <li><strong>What the round decided.</strong> "Ofício / Memorando" takes exactly <strong>8</strong> of the 11 descriptions no typology covers <strong>under all three formats</strong>: the dustbin is not a wording problem, and no label shape makes an uncovered form covered. <code>bare</code> wins, so the default is not changed, nothing moves in the worker, and #30 keeps its decision with the measurement it never had. Adopting a proposition would have cost a full re-queue under the identity #117 had just made meaningful, for no measurable gain.</li> <li><strong>Not verified, and said so.</strong> The second half of the issue's observation — that with the archival history added the same drawer answers nothing because the model abstains on all 11 — was <strong>not</strong> re-measured: the round was scoped to the shipping configuration, where the default stays. It is a question about the columns that #109 already priced at 2.62× the cost, not about the label shape.</li> </ul> <h2 id="2026-10-17-the-typology-records-the-refusal-it-saw-110">2026-10-17 — the typology records the refusal it saw (#110)<a class="headerlink" href="#2026-10-17-the-typology-records-the-refusal-it-saw-110" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>a554cbe..7a40f14</code></li> <li><strong>Pages:</strong> three were reported stale by <code>docs/_hooks/freshness.py</code>; two changed and one is no-change, and the reason it is no-change is the honest part.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the <code>archive_ai_review_queue</code> paragraph said the queue carries <code>SUBJECT_LOW_CONFIDENCE</code> "for tags the subject classifier could not place"; it now also names <code>TYPOLOGY_LOW_CONFIDENCE</code> and says that row is where the score the run saw is kept, because <code>typology_id</code> stays NULL for a refusal and for a description no run reached.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the <code>typology</code> worker's bullet said only that it classifies by zero-shot; it now says what a refusal leaves behind.</li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: it moved only because <code>models/enums.py</code> is one of its declared sources, and no screen reads this row. The issue's Definition of Done deliberately left the curator card out — the read side has to answer the count, and surfacing it is a separate decision, which is now <strong>#129</strong>: the descriptions the classifier refused need a screen, and that issue decides first whether it belongs to <code>/arranjo/tipologias</code> or widens <code>/qualidade/anomalias</code>.</li> <li><strong>What the round measured and verified.</strong> The migration was applied against a scratch database and against the development one: <code>upgrade head</code> from <code>3f9c1a7be204</code> adds the value, the enum then lists all three, and <code>downgrade -1</code> runs. The development database's revision matched this migration's <code>down_revision</code> exactly, so nothing else moved with it. <code>alembic check</code> reports no new upgrade operations.</li> <li><strong>Not verified, and said so.</strong> No worker was run against the real collection, so the number of rows this writes there is a projection from the #109 abstention rate (0.000 on the title at 0.40, 0.020 with the archival history) and not an observation. The curator screen that would make these rows actionable does not exist and is not in this DoD.</li> </ul> <h2 id="2026-10-17-the-typology-bench-simulates-a-catalogue-edit-111">2026-10-17 — the typology bench simulates a catalogue edit (#111)<a class="headerlink" href="#2026-10-17-the-typology-bench-simulates-a-catalogue-edit-111" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>7a40f14..5da851b</code></li> <li><strong>Pages:</strong> none. <code>docs/_hooks/freshness.py</code> reported every page fresh: the round touched <code>testing/evaluation/typology_quality.py</code> and its unit tests, and no guide declares a bench as a source. The ledger entry exists to record the verdict and advance the floor.</li> <li><strong>What the round measured.</strong> Four catalogues over the same 111 signed rows at the shipping configuration: as it is (agreement 0.775, macro-F1 0.484, 25 misses), without "Ofício / Memorando" (<strong>0.856</strong>, 0.558, 16), with the three missing forms named "Livro de Registro" (0.378, 0.272, 69) and with them named "Livro de Sepultamento" (0.703, 0.476, 33 — against a predicted 33).</li> <li><strong>What the round concluded.</strong> On this axis the <strong>name is the taxonomy</strong>: "Ofício / Memorando" is a form the collection has zero of and the model's third favourite answer; "Livro de Registro" collides lexically with the fonds' own titles ("Registros Fotográficos") and collapses the axis; a discriminative name takes exactly its documents. A definition documents and cannot decide, because #30 keeps it out of the prompt — so the catalogue edit is a naming decision, and the bench prices one in about four minutes against a 2.9-hour re-queue.</li> <li><strong>Not verified, and said so.</strong> No catalogue change was written to the database: the round measured simulations, and the real re-queue cost is the projection from the 2.2 s per document #109 measured. The 11 control rows read as misses in the extended catalogues because the gabarito says <code>null</code> for them — correct for the product, wrong for the placar, which is the re-labelling issue #111 names.</li> </ul> <h2 id="2026-10-17-the-typology-catalogue-decided-and-the-strata-follow-it-111">2026-10-17 — the typology catalogue decided, and the strata follow it (#111)<a class="headerlink" href="#2026-10-17-the-typology-catalogue-decided-and-the-strata-follow-it-111" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>5da851b..HEAD</code></li> <li><strong>Pages:</strong> none reported stale. The round touched <code>testing/evaluation/typology_quality.py</code> and its tests; no guide declares a bench as a source, and the catalogue itself is data of the installation and does not ship, so nothing a page documents moved.</li> <li><strong>The decision, taken and applied.</strong> Three forms with zero instances retired (<code>Ofício / Memorando</code>, <code>Ata de Reunião</code>, <code>Dossiê Funcional</code>), three forms the collection has added with names chosen for their discriminative power (<code>Livro de Sepultamento</code>, <code>Requerimento à Câmara Municipal</code>, <code>Publicação Impressa</code>), and three definitions corrected to say what the curator's own labels say the drawers hold. Measured: agreement 0.775 → <strong>0.865</strong> without the dead forms, and the three added forms take exactly their documents (8 burial registers at 0.968–0.982, 2 Câmara petitions at 0.926–0.939).</li> <li><strong>Reversibility.</strong> The typology catalogue has no revision ledger, so the before-state of all six touched rows — names, ids and the full previous <code>context_description</code> — is recorded in the issue comment that states the decision. That is what makes the change undoable.</li> <li><strong>Verified.</strong> <code>--pick</code> runs against the new catalogue and proposes 8/8, 3/3 and 1/1 for the three new strata; <code>count_pending</code> is 4,813 before and after, because the re-queue #117 declared was already owed and this edit changes what that run produces, not whether one is owed.</li> <li><strong>Declared, not closed.</strong> The control bucket proposes 1 of 1 candidate, so keeping a false-positive control now needs the curator to name what stays out of scope; 31 descriptions match no stratum and are photographs <code>PHOTO_STRATUM</code> misses; and the revised gabarito needs a human signature, which the bench's <code>status</code> gate exists to require and which is not mine to give.</li> </ul> <h2 id="2026-10-17-a-bench-for-the-ner-extractor-113">2026-10-17 — a bench for the NER extractor (#113)<a class="headerlink" href="#2026-10-17-a-bench-for-the-ner-extractor-113" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>5da851b..a76dc63</code></li> <li><strong>Pages:</strong> none reported stale. The round touched <code>testing/evaluation/</code> and its tests; no guide declares a bench as a source, and the set's shape is versioned in <code>testing/evaluation/README.md</code>, which is not a page of the site.</li> <li><strong>What the round built.</strong> <code>testing/evaluation/ner_quality.py</code>, term-level P/R/F1 per type and overall with exact match only, a relaxed recall printed beside it, and the misses partitioned into hits, boundary, type confusion and missing plus spurious. Nothing is written: the session is opened, read and rolled back. 48 unit tests and 16 integration tests, one of which pins the composed text against what the worker hands the engine.</li> <li><strong>Three places the Definition of Done could not be met as written</strong>, all recorded in the issue: <strong>there are no spans anywhere</strong> (<code>EntityExtractionEngine.extract</code> returns a DTO with <code>name</code> and <code>entity_type</code>, and the link table has no offsets), so the boundary breakdown is a token-containment measure that counts the defect without locating it; <code>input_text</code> is deliberately not in the file, since the sheet reads the text through the worker's own composition; and a row is <em>read</em> when it carries a <code>confidence</code> or a <code>note</code>, so an untouched proposal is counted apart rather than scored as "no entity".</li> <li><strong>What it found in the collection, measured directly.</strong> 1,650 documents carry <code>worker_ner_v2 = "DONE"</code>, 41 carry <code>worker_ner_v1 = "DONE"</code>, 3,142 carry no NER stamp at all, and <strong>not one carries a producer fingerprint</strong>: the NER output is unattributable in full, which is the concrete case behind #135.</li> <li><strong>Not verified, and said so.</strong> No human-corrected set exists, so there is <strong>no measurement of the extractor yet</strong> — only the plumbing proof (a set whose <code>expected</code> is the live extraction scores 1.000, and a stored set with one truncated term shows the exact-versus-relaxed pair). The proposal waits for a curator, and the first real number is the one measured against the signed set.</li> </ul> <h2 id="2026-10-18-the-hybrid-ranking-and-the-numbers-the-issue-was-missing-38">2026-10-18 — the hybrid ranking, and the numbers the issue was missing (#38)<a class="headerlink" href="#2026-10-18-the-hybrid-ranking-and-the-numbers-the-issue-was-missing-38" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>a76dc63..8b92fef</code></li> <li><strong>Pages:</strong> <code>guides/curate.md</code> — <strong>updated</strong>: the search section named two rankings and carried a measurement that was not about them, and now names the three with the rule that decides between them and the limit of the evidence. <code>guides/install.md</code> — <strong>no-change</strong>: <code>lib/screens.ts</code> moved, and what moved is the one-line description of an existing screen; no installation fact, screen count or label changed.</li> <li><strong>The issue's premise was measurably wrong, and that was the first finding.</strong> <code>#38</code> quoted <em>"MRR fell 0.019 while Hit@10 rose to 0.625"</em> as evidence that the semantic ranking was weak against the lexical one. Both numbers come from <code>9a075bc</code> (2026-10-04) and are the before/after of the <strong>excerpt catalogue on the semantic arm</strong> (<code>MRR 0.358 → 0.339</code>, <code>Hit@10 0.562 → 0.625</code>), measured on 3,608 descriptions. No lexical-versus-semantic comparison existed anywhere in the repository, the bench the issue named (<code>retrieval_quality.py</code>) has no lexical arm and never had one, and the page that repeated the number — <code>guides/curate.md</code> — cited the <em>improved</em> figure as proof of weakness.</li> <li><strong>What the round built.</strong> <code>mode=hybrid</code>: the full-text and semantic rankings built independently and fused by <code>reciprocal_rank_fusion</code> (<code>domains/archive/domain/search.py</code>, <code>RRF_K = 60</code>), over the top <code>RRF_DEPTH = 100</code> of each arm. <code>total</code> is the union of the full-text matches with the semantic <strong>window</strong> and not every embedded document; the page is the fused window first and the full-text order after it; <code>rank</code> is the fusion's score inside the window and the relevance of the ordering outside it. The facet rule survives the fusion: the window is rebuilt for each dimension that excludes its own filter. One <code>SearchMode</code> alias replaces the <code>Literal</code> written three times, and the full-text mode stays the default, so a lexical request still never loads the model.</li> <li><strong>The measurement, per stratum, through the product path</strong> (<code>retrieval_modes.py</code>, 16 labelled queries, 4,831 descriptions): sentence — lexical <code>0.438 / 0.258</code>, semantic <code>0.562 / 0.293</code>, <strong>hybrid <code>0.625 / 0.296</code></strong> (Hit@10 / MRR); term — lexical <code>0.875 / 0.703</code>, semantic <code>0.562 / 0.338</code>, <strong>hybrid <code>0.938 / 0.679</code></strong>. The fusion wins on both strata and is therefore the mode that removes the choice; it is not a free win, and the term stratum says so: one more query inside the top ten and still 0.024 below the full-text ranking on MRR, because agreement demotes a confident full-text hit the semantic arm disagrees with.</li> <li><strong>Two properties the arithmetic had to be corrected for, both measured.</strong> RRF is <strong>not</strong> a dominance order — a first place plus a third beats a second place twice by 8·10⁻⁶ (<code>1/61 + 1/63</code> against <code>2/62</code>), because the reciprocal is convex — so neither the helper's docstring nor the tests claim that a shared document always wins. And the window depth is a parameter that moves the result: the pre-implementation simulation over the top 50 of each arm gave the term stratum <code>MRR 0.722</code>, the shipped window of 100 gives <code>0.679</code>.</li> <li><strong>Latency is the full-text one plus about 7%</strong> (6.40 s against 5.46 s per query on the sentence stratum, 13.69 s against 12.85 s on the term stratum, measured with the model loaded outside the timed loop). The absolute numbers are high and are <strong>pre-existing</strong>: they are the cost of a broad term in the full-text arm, which the fusion adds six semantic window queries to rather than creates.</li> <li><strong>What the round looked at.</strong> Two screens <strong>were rendered</strong>, not inferred: the headless shell over CDP against the dev server and the scratch database (<code>scrinalia_render</code>, 4,842 descriptions), with a session row inserted for the round and deleted after. <code>.render/hybrid.png</code> shows the hybrid mode selected, the note on screen and the results of <code>matadouro</code> — 139 descriptions, the facets filled, and a description <strong>without the term in its title</strong> ranked above the ones that carry it, which is the fusion visible on the page; <code>.render/semantic.png</code> shows the semantic note and the same query answering <strong>4,842 descriptions</strong>, the whole embedded collection, which is the count the fusion exists to stop reporting.</li> <li><strong>Not verified, and said so.</strong> The public surface's <code>mode=hybrid</code> is covered by the contract and by the repository tests, and was <strong>not</strong> rendered: no screen was opened against <code>/api/v1/public</code>. The latency was measured against the live collection on this machine and not under load, and the bench's labelled set remains sixteen queries whose gold is derived from the title — the report prints that limit next to every number, and the numbers themselves are in the issue comment, not in a page.</li> </ul> <h2 id="2026-10-18-the-search-that-filtered-after-joining-and-the-page-that-under-declared-its-sources-140">2026-10-18 — the search that filtered after joining, and the page that under-declared its sources (#140)<a class="headerlink" href="#2026-10-18-the-search-that-filtered-after-joining-and-the-page-that-under-declared-its-sources-140" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>8b92fef..c884152</code></li> <li><strong>Pages:</strong> <code>guides/operate.md</code> — <strong>updated</strong>: its last known-limit bullet said that ranking by semantics alone was weaker than the lexical one and that <em>combining the two rankings is open work</em>. Both halves had stopped being true in #139, which shipped the fused mode, and the page had never declared the search code among its <code>sources:</code>, so no freshness report could see it. The bullet now says what decides between the three modes, points at the curation guide that owns the table and at the bench that measures them, and the four files it depends on (<code>domain/search.py</code>, <code>repository/document_repo.py</code>, <code>services/document_service.py</code>, and the two retrieval benches) are declared — which is why this round was reported at all.</li> <li><strong>The defect the round fixed, measured before it was touched.</strong> A selective term (<code>enchente</code>, 12 matches) took <strong>18.0 s</strong> and a broad one (<code>curitiba</code>, 3,286) <strong>6.6 s</strong>; two facet queries were 98% of that. The term reached the facets as an <code>OR</code> of correlated <code>EXISTS</code>, so it was evaluated once per link row — 64,504 entity links to find 12 descriptions — and the <code>OR</code> kept the planner away from the <code>search_vector</code> GIN index. The hardware was ruled out by measurement: 78 MB table, 128 MB <code>shared_buffers</code>, 99.98% cache hit ratio, <code>shared hit=598,994</code> and zero reads in the plan, no container limits, <code>VACUUM (ANALYZE)</code> worth 72 ms of 9,020. The commit before the fusion took <strong>17,974 ms</strong>, so nothing here was the fusion's doing.</li> <li><strong>What the fix changed.</strong> The full-text arm is a set of description ids (text ∪ tag ∪ entity), computed once per query and handed to the page, the count and every facet; the taxonomy boost reads the same two sets instead of a correlated <code>EXISTS</code> per candidate row; the count stopped computing a rank expression it does not read; and the fused mode's candidate clause became one union of two id sets instead of an <code>OR</code> of them (2.1 s → 0.31 s on its own).</li> <li><strong>The measurement, and the one regression.</strong> Eighteen query/mode/filter cases on the live collection, results <strong>identical</strong> — same ids, order, ranks to ten decimals, <code>total</code> and all five facet counts: lexical 17.5 s → 0.21 s and 6.4 s → 0.41 s, hybrid 18.0 s → 0.31 s, semantic unchanged at 0.15 s. One case got slower, a term combined with a highly selective facet filter (0.19 s → 0.29 s), because the term set is resolved before the filter now — the term is the floor for any query that has one.</li> <li><strong>What was looked at, and what was not.</strong> The screens were <strong>not</strong> rendered and did not need to be: no SPA file changed, and the 18-case digest is the evidence that the answer is byte-for-byte the one the routes already served. The public surface was not exercised separately.</li> <li><strong>A guard, and proof that it bites.</strong> <code>testing/integration/archive/repository/test_search_predicate_shape.py</code> pins the shape of the emitted SQL, because the defect is invisible to a behavioural test: reverting the clause returns exactly the same page. Both guards were run against the code before the fix and fail there, and pass after it.</li> </ul> <h2 id="2026-10-18-the-judge-that-wrote-a-failure-as-a-verdict-and-the-preset-that-could-not-run-122">2026-10-18 — the judge that wrote a failure as a verdict, and the preset that could not run (#122)<a class="headerlink" href="#2026-10-18-the-judge-that-wrote-a-failure-as-a-verdict-and-the-preset-that-could-not-run-122" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>c884152..5bfb1f8</code></li> <li><strong>Pages:</strong></li> <li><code>guides/install.md</code> — <strong>updated</strong>: the resource table said the presets name <code>granite4.1:3b</code> and <code>gemma4:e4b</code>, and the first was retired in this change, so the table names one model now. The "Ollama is down" section gained what the retirement means for an operator: the name is not offered, the health probe no longer requires the model, writing it is refused, and a row written before the retirement resolves with the reason on the screen.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same two places, same structure.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the <code>conflict-judge</code> bullet now says a call the engine could not make raises instead of becoming a verdict, so the pair leaves no row and is asked again on the next run; the <code>--preset</code> example no longer lists the retired name.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same two places.</li> <li><strong>The measurement the retirement rests on, taken in this round.</strong> Ollama 0.24.0 on this machine: <code>granite4.1:3b</code> answers <code>{"error":"llama runner process has terminated: signal arrived during cgo execution"}</code> to a one-word prompt, while <code>gemma4:e4b</code> and <code>gemma3:4b</code> answer <code>ok</code> on the same server. The model <strong>is installed</strong>, so the health probe's missing-model list could never have caught it — which is why the retirement is a code decision and not an installation note.</li> <li><strong>What the fix changed, and the gate that proves it.</strong> The engine raises <code>EngineExecutionError</code> instead of returning <code>winner="TAG"</code> with confidence <code>0.0</code>; the worker leaves no <code>llm_decision</code> and the pair stays pending; the bench's <code>FAILURE_REASON_PREFIX</code> matching is deleted. The pair of tests is <code>testing/unit/archive/engines/test_ollama_tag_entity_conflict.py</code> — a transport error, a 500 and a 200 whose body is not the schema all raise, and a valid body still returns a verdict — and the worker integration test: no queue row, <code>count_judged == 0</code>, and the pair is asked again on the next run.</li> <li><strong>What was rendered.</strong> The panel's worker screen, with a session row inserted for the round and deleted after it: <code>.render/workers-122.png</code> shows row 5 (<code>conflict-judge</code>) resolving to <code>ollama_judge · gemma_4b_local — gemma4:e4b</code>, and <code>.render/workers-122-stale-open.png</code> shows the same row after a <code>granite_local</code> row was written directly into <code>archive_worker_settings</code> (deleted after the shot): the row reads <code>padrão alterado</code>, resolves to <code>gemma_4b_local</code>, and the note at the bottom carries the reason. The retired name appears in no preset select.</li> <li><strong>Not verified, and said so.</strong> The bench was <strong>not</strong> run against the real model in this round: the measurement above is a direct call to Ollama, so the report's own columns (agreement, constant baseline, Δ) are unchanged from #114 and were not re-measured. The refusal of the retired name is covered by tests on <code>get_engine</code>, <code>describe_config</code> and the API's write path; it was not exercised through the SPA's save button. The title-quality axis's failure path was left as it was: <code>check_title</code> still swallows a transport error into <code>is_suspect=False</code> with confidence <code>0.0</code>, the same defect class in another worker, and it is outside this issue's definition of done — no test covers it either way, which is a gap this round did not close.</li> </ul> <h2 id="2026-10-18-the-engine-of-an-llm_check-rule-stops-being-free-text-124">2026-10-18 — the engine of an LLM_CHECK rule stops being free text (#124)<a class="headerlink" href="#2026-10-18-the-engine-of-an-llm_check-rule-stops-being-free-text-124" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>5bfb1f8..d18c3f0</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <em>Regras</em> section gained what the <code>LLM_CHECK</code> rule's two new selects are fed by (<code>GET /api/v1/quality/cleaning-rules/engines</code>, the title-quality axis's catalogue), that the pair is checked when the rule is written and refused as a <code>422</code>, that an empty engine still means the default, and what a rule written before that door does now.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph, same structure.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the <code>quality-validator</code> row of the queue table now says its producer identity is the "no model" one <strong>also</strong> when the rule's engine cannot be built, because that is the run the validator actually makes.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same row.</li> <li><strong>The defect, measured before it was touched.</strong> The screen's only hint was the placeholder <code>ex.: ollama</code>, and <code>ollama</code> is not a registered engine — the axis holds <code>ollama_title_check</code> alone. Nothing validated the pair on the way in (<code>CleaningService.create_cleaning_rule</code> checked the regex and nothing else), and the registry's <code>ValueError</code> escaped <code>worker_quality_validator.execute</code>, so the run was recorded <code>FAILED</code> and the validator validated nothing while the rule stayed active.</li> <li><strong>What the change is, in three pieces.</strong> The catalogue is one definition (<code>describe_axis_engines</code>, shared with the operations panel) behind a new read route; the write validates the pair through the registry's own <code>describe_config</code> and raises <code>InvalidLLMCheckEngineError</code> (<code>422</code>) whatever the rule kind is; and the two readers of a rule saved before that door — <code>execute</code> and <code>producer_identity</code> — ask one question (<code>unbuildable_rule_reason</code>) and degrade to the deterministic stage and the "no model" identity.</li> <li><strong>What was rendered.</strong> <code>.render/cleaning-rules-124.png</code>: the <em>Nova regra</em> form with <code>Tipo</code> = <em>consulta o modelo</em>, <code>Engine</code> = <em>padrão (ollama_title_check)</em> and <code>Preset</code> = <em>sem preset (o padrão do motor)</em>; the preset list read out of the DOM was <code>gemma_4b_local — gemma4:e4b</code>, and the placeholder that named a non-existent engine is gone. The catalogue the screen reads was checked against the live route before the shot: <code>[{"name":"ollama_title_check","presets":[{"name":"gemma_4b_local","config":{"model":"gemma4:e4b",...}}]}]</code>.</li> <li><strong>Not verified, and said so.</strong> The refusal was exercised through the real route and the real service by the integration test (a <code>POST</code> naming <code>ollama</code> answers <code>422</code> with <code>InvalidLLMCheckEngineError</code>), but <strong>not</strong> by clicking <em>Salvar e ativar</em> in the browser. The legacy degradation is covered against the test database; no live run against Ollama was made, so the sentence the log carries in that path was read in the captured log, not on a screen.</li> </ul> <h2 id="2026-10-18-the-legacy-stamps-stop-reading-as-a-producer-137">2026-10-18 — the legacy stamps stop reading as a producer (#137)<a class="headerlink" href="#2026-10-18-the-legacy-stamps-stop-reading-as-a-producer-137" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>d18c3f0..e625d49</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: <em>What each queue means</em> now opens the stamp into the three things a slot can name (with a producer, without a known one, without a record) instead of one <code>processed</code> number; the paragraph on the <code>ERROR</code> mark says the legacy value is <code>PRODUCER_UNKNOWN</code> now and that the queue is untouched either way; the <code>/sistema/workers</code> row of the panel table says what the card shows.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same three places.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the stamp vocabulary is two types with different writers (a status raises in an identity slot), the five identity keys and <code>PRODUCER_UNKNOWN</code>, and the migration that renamed the legacy rows — including what it deliberately did not touch (a key that was never there, and the embedding's bare text hash).</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same paragraph.</li> <li><strong>What was rendered.</strong> <code>.render/workers-137.png</code>: the headless shell over CDP against the dev server and the reference collection, with a session row inserted for the round and deleted after it. The NER card, expanded, reads <code>0 com produtor · 1.650 sem produtor conhecido · 3.181 sem registro</code> under <code>Fila (descrições)</code>, against <code>4.813</code> pending — the three numbers the issue asked to be readable, on the collection where the single number used to say 1.691.</li> <li><strong>The migration was run, not only written.</strong> On the reference collection, <code>f647dd7e6ad2 -> a7c1e5b9d3f2</code> rewrote 3,511 document slots and 921 tag slots (<code>worker_ner_v2</code> 1,650, <code>worker_quality_validator_v1</code> 1,650, <code>worker_macro_category_v1</code> 921, <code>worker_typology_classifier_v2</code> 129, and the retired <code>worker_ner_v1</code> and <code>worker_typology_classifier_v1</code> 41 each), with 0 fingerprints before and after and the pending counts unchanged. The downgrade was run too: the round trip returned 1,691 values to <code>DONE</code> and back to <code>PRODUCER_UNKNOWN</code>. <code>alembic check</code> reports no drift.</li> <li><strong>Not verified, and said so.</strong> No worker was run after the migration, so no document was seen being healed from <code>PRODUCER_UNKNOWN</code> to a real fingerprint: that the next plain run re-reads those units is the predicate in <code>identity_stamp_conditions</code>, read in the code and pinned by the unit tests, not observed as a run. And the panel was read on the reference collection only — the tag breakdown of <code>macro-category</code> is covered by the repository test and was not looked at on a screen.</li> </ul> <h2 id="2026-10-22-the-thumbnail-failure-mark-is-the-plain-error-149">2026-10-22 — the thumbnail failure mark is the plain ERROR (#149)<a class="headerlink" href="#2026-10-22-the-thumbnail-failure-mark-is-the-plain-error-149" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>0c09f99..d1494f1</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the failure count now names <code>ERROR</code> as the value and the <code>thumbnail_failed</code> key as the thumbnail's own ledger, instead of suggesting a second failure spelling.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same paragraph, same structure.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the <code>thumbnail_failed</code> row names <code>ERROR</code> as the value, so the table no longer leaves the reader to infer it.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same row.</li> <li><strong>Measured, not assumed.</strong> On the reference collection (<code>memoriacuritibana</code>) the key has <strong>0</strong> rows; the migration was run <strong>up and down</strong> on a scratch database with a seeded <code>True</code> row and returned it to <code>True</code> on the downgrade. The thumbnail queue and its <code>failed</code> count read the key's presence, not the value, so the queue is unchanged.</li> <li><strong>Not verified, and said so.</strong> No screen changed, so no render was made; the panel's <code>failed</code> number for the thumbnail is covered by its counter test, not looked at in a browser. The #148 gate is test-only and has no documentation page.</li> </ul> <h2 id="2026-10-23-the-spacy-pipeline-leaves-the-lock-for-a-build-step">2026-10-23 — the spaCy pipeline leaves the lock for a build step<a class="headerlink" href="#2026-10-23-the-spacy-pipeline-leaves-the-lock-for-a-build-step" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>d1494f1..8af438e</code></li> <li><strong>Pages:</strong></li> <li><code>guides/install.md</code> — <strong>updated</strong>: step 3 gains the install command (<code>uv pip install --python .venv/bin/python --require-hashes -r models/requirements.txt</code>) and the paragraph saying why the pipeline is not in the lock and that <code>uv sync</code> prunes it; <code>models/requirements.txt</code> joins the page's <code>sources:</code>.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same step and paragraph.</li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the report named it for <code>packages/api-contract/openapi.json</code>, and that file's whole diff in the range is <code>info.version</code> from <code>1.2.0</code> to <code>1.3.0</code> (<code>5a41a2d</code>). No route, schema or screen moved, so the page still holds.</li> <li><strong>Measured, not assumed.</strong> <code>uv lock</code> resolved 214 packages without <code>pt-core-news-lg</code>; <code>uv sync --locked</code> uninstalled the package; the install command put it back in 6.06 s against a warm cache, and <code>importlib.metadata.version("pt-core-news-lg")</code> answered <code>3.8.0</code>. The requirements line carries the sha256 the lock used to hold.</li> <li><strong>Not verified, and said so.</strong> The image was not built here: the Dockerfile step is that same command, run after <code>uv sync</code> in the same stage. And no Dependabot run has happened since the change, so the update job it exists for is still unobserved.</li> </ul> <h2 id="2026-10-24-the-entity-link-that-could-not-say-who-wrote-it-135">2026-10-24 — the entity link that could not say who wrote it (#135)<a class="headerlink" href="#2026-10-24-the-entity-link-that-could-not-say-who-wrote-it-135" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>d1494f1..1ac157d</code></li> <li><strong>Pages:</strong></li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: the <code>archive_document_entities</code> row carries <code>origin</code> and <code>created_at</code>, and says <code>created_at</code> is null for a link that predates the column.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same row.</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <code>Assuntos</code> tab names the origin of each link, the <code>Confirmar</code> action and its consequence, and the fact that a confirmation which changes no names writes no revision.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph.</li> <li><strong>Measured, not assumed.</strong> On a copy of the reference collection (<code>memoriacuritibana</code>, 64.504 links over 4.811 documents) the migration ran up and down and <code>alembic check</code> reported no drift; the backfill left exactly <strong>64.504</strong> rows as <code>UNKNOWN</code> and none as <code>AI</code>. The screen was rendered against that copy migrated to head: <code>confirmada</code>, <code>IA</code> and <code>não rastreada</code> appear on the same list, and clicking <code>Confirmar</code> on an <code>UNKNOWN</code> row flipped it to <code>HUMAN</code> both in the database and on screen.</li> <li><strong>Not verified, and said so.</strong> No NER worker was run after the change: the <code>IA</code> row in the render was written through <code>EntityRepository.bulk_link_entities</code> — the worker's own write path — and not by a full extraction. And a confirmation that changes no names writes no revision, so the <code>changed_by</code> of that act is not stored; the link's origin is the record, which the guide states.</li> </ul> <h2 id="2026-10-24-the-ban-that-matched-every-name-160">2026-10-24 — the ban that matched every name (#160)<a class="headerlink" href="#2026-10-24-the-ban-that-matched-every-name-160" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>1ac157d..9923231</code></li> <li><strong>Pages:</strong></li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: <code>domain_stopwords</code> carries <code>match_mode</code>, and <code>domain_ner_exclusions</code> matches the exact spelling; the table now says what the worker reads.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same two rows.</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the Stopwords tab gains the <code>Casamento</code> bullet — the PATCH, the two modes and why an exclusion is exact.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same bullet.</li> <li><strong>Measured, not assumed.</strong> On the reference collection the migration ran up, down and up again and <code>alembic check</code> reported no drift; the data decision set 9 stopwords to <code>EXACT</code> and removed the exact <code>câmara municipal de curitiba</code>. The live measurement on the signed #138 set went from <strong>P 0.909 / R 0.920 / F1 0.915</strong> with 13 blocked gold terms to <strong>P 0.917 / R 0.956 / F1 0.936</strong> with none; ORG recall 0.568 → 0.611, boundary 27 → 12, spurious 109 → 115.</li> <li><strong>Not verified, and said so.</strong> No NER worker ran over the whole collection after the change: the number is the bench's <code>live</code> mode over the 122 labelled documents, not a re-extraction of the 4,813. The screen was rendered against a copy of the collection, where flipping <code>alameda</code> to <code>TOKEN</code> changed both the select and the row in the database; the copy was then discarded.</li> </ul> <h2 id="2026-10-24-the-typology-refusals-have-a-screen-129">2026-10-24 — the typology refusals have a screen (#129)<a class="headerlink" href="#2026-10-24-the-typology-refusals-have-a-screen-129" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>9923231..8e3a903</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the Tipologias section gains the refusal queue, <code>GET /api/v1/typologies/refusals</code>, the dossier's <code>PATCH</code> write, the empty state's meaning and the note that there is no durable "no typology" verdict; the catalogue table row names the queue and its reversibility.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph and table row.</li> <li><code>guides/data-model.md</code> — <strong>no-change</strong>: the report named it for <code>models/governance.py</code>; the page names <code>llm_decision</code> but not its length, and the queue paragraph (<code>TYPOLOGY_LOW_CONFIDENCE</code> and the score it saw) is unchanged.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the report named it for <code>router.tsx</code>; the diff is the typology route's <code>offset</code> search, not the operations panel, and the typology worker paragraph already says the refusal is recorded with the score. The screen that settles it belongs to the curation guide.</li> <li><strong>Measured, not assumed.</strong> The migration widened <code>llm_decision</code> to 100; <code>alembic upgrade head</code> applied it on the dev database (<code>scrinalia_zeroed</code>) and <code>alembic check</code> reports no new operations. The new API test pins the page to the predicate and the count, and the route to 401/200/403. The index was not added: on the reference collection with 3,000 descriptions available, 100 queue rows cost <strong>0.5 ms</strong> for the page with or without it; 5,000 rows cost <strong>15.8 ms / 3.9 ms</strong> (page/count) without and <strong>18.7 / 4.8</strong> with; 50,000 rows cost <strong>85.6 / 15.0</strong> without and <strong>78.3 / 15.6</strong> with. The only gain was 8.5% on the page at 50k, a queue this collection does not have.</li> <li><strong>Looked at, not assumed.</strong> The screen was rendered against the dev database with three seeded refusals (0.39, 0.30, 0.05): the queue showed the current titles, the labels and the scores in that order. Selecting <code>Planta Arquitetônica</code> and clicking <code>Classificar</code> removed the row, changed the count from 3 to 2, wrote <code>typology_id=1</code> and <code>review_status=HUMAN_APPROVED</code> on <code>16124</code> and one revision. The seeded rows, the revision and the temporary account were removed afterwards, and the document returned to <code>NEEDS_REVIEW</code> with no typology. The empty state was rendered too, and says what empty means.</li> <li><strong>Not verified, and said so.</strong> The index measurement is a synthetic queue over the reference collection, not a real worker run; no typology worker ran after the change. The page was rendered on the zeroed dev database's 101 documents, not on the 4,812 of the reference collection, so the screenshot proves the screen and the write, not a production-sized list.</li> </ul> <h2 id="2026-10-24-the-declared-worker-options-are-validated-published-and-recorded-120">2026-10-24 — the declared worker options are validated, published and recorded (#120)<a class="headerlink" href="#2026-10-24-the-declared-worker-options-are-validated-published-and-recorded-120" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>8e3a903..dbb00c8</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: "What a worker accepts" (the declared options, the two column options and the <code>AI_TEXT_COLUMNS</code> they are checked against, the run-only <code>limit</code>), the <code>--engine-option</code> flag and its row, the two doors, the five refusals, the run ledger's <code>options</code> and the stale row that is ignored with a note.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same sections.</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the run panel is rendered from <code>declared_options</code> with the pass-through block as the only free-form one, the settings form no longer has a JSON box and hides <code>limit</code>, and the ledger row shows the effective options.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same three passages.</li> <li><code>guides/data-model.md</code> — <strong>updated</strong>: <code>archive_worker_runs.options</code>, and the note that the ledger carries the resolved engine configuration <em>and</em> the resolved worker options.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same row and note.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: the report named it for <code>pyproject.toml</code>, and the only movement there is the release's version bump.</li> <li><strong>Measured, not assumed.</strong> <code>uv run pytest</code> answers <strong>2028 passed, 1 skipped</strong> (the 34 new unit tests of <code>test_worker_options.py</code>, the 3 of the pin in <code>test_option_columns_pin.py</code>, and the new cases in the runner and service suites); <code>ruff check</code>/<code>ruff format --check</code> and <code>basedpyright</code> answer zero. <code>alembic upgrade head</code> applied <code>efe037349552</code> on the dev database and <code>alembic check</code> reports <strong>no new upgrade operations</strong>. <code>bun run contract</code> regenerated the document and the client, and <code>tsc</code>, ESLint and the Vite build are green.</li> <li><strong>The pair was measured, not asserted.</strong> The typology benchmark and the write path now refuse the same spelling through the same helper: <code>unknown_ai_columns(["original_title", "admin_bio_historia"])</code> answers <code>["admin_bio_historia"]</code> for both, and the API's sentence names the five known columns. The measured default of <code>columns_to_extract</code>, resolved by the worker itself, is <code>original_title, admin_bio_history, provenance, scope_content</code>.</li> <li><strong>Looked at, not assumed.</strong> The three screens were rendered on the dev database through a temporary admin account: the settings card of <code>typology</code> shows five column checkboxes, the confidence field and the <code>force</code> flag, <strong>no JSON box and no <code>limit</code></strong>; the run panel of <code>ner</code> shows the same fields plus <code>limit</code>, and its opened <em>Repasses ao engine (avançado)</em> block lists <code>device, disable, model</code> as what the axis passes; the ledger shows a seeded run with <code>columns_to_classify=original_title,scope_content · confidence_threshold=0.4</code> and a row without the column reading "opções não registradas nesta execução". The seeded rows and the account were removed afterwards.</li> <li><strong>Not verified, and said so.</strong> No worker was run after the change: the recorded-options path is proved by the integration test with a fake worker (the record of <code>ner</code> carries the resolved default columns and not the <code>None</code> of the signature), not by a real extraction or classification over the collection on this machine, and the screens were rendered over the 100 descriptions of the dev database rather than the reference collection. The <code>--engine-option</code> coercion is covered by unit tests on argument parsing, not by a run that loaded a model with an overridden <code>device</code>.</li> </ul> <h2 id="2026-10-24-the-spas-request-bodies-are-derived-too-no-page-changed">2026-10-24 — the SPA's request bodies are derived too (no page changed)<a class="headerlink" href="#2026-10-24-the-spas-request-bodies-are-derived-too-no-page-changed" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>dbb00c8..77bca6b</code></li> <li><strong>Pages:</strong> <strong>none</strong>, and that is the verdict this entry records. The change lives inside <code>apps/curator/src/api/</code> and <code>testing/unit/curator/</code>, and no page declares either path among its <code>sources:</code> — <code>uv run python docs/_hooks/freshness.py</code> answers "every page is fresh" and names none. What changed is a <strong>rule file</strong>, <code>.dsh/skills/curator-ui/SKILL.md</code>: a request body is derived from the model the route declares, never typed out, with the measured asymmetry beside it.</li> <li><strong>Why the round exists:</strong> #93 recorded the half #88 left out. The query position fails loudly (an unknown filter is ignored and the screen filters by nothing); the body position fails the other way, because pydantic refuses a missing <em>required</em> field and silently ignores an unknown one. Sizing it against the checkout found <strong>fifteen</strong> functions typing a body by hand, not the thirteen the issue counted: the issue already subcounted (fourteen at <code>32d19f1</code>, the commit nearest its date) and <code>setStopwordMatch</code> arrived afterwards in <code>9923231</code>.</li> <li><strong>What the round measured.</strong> The thirteen request models do not share one policy: ten keep pydantic's default (<code>extra="ignore"</code>) and three declare <code>extra="forbid"</code> — <code>SubjectExclusionRequest</code>, <code>SubjectExclusionRemovalRequest</code>, <code>ConflictPreviewRequest</code>. Renaming an optional field is silent in ten of the thirteen: <code>EntityMergePreviewRequest</code> with <code>new_name</code> → <code>new_nam</code> validates and the merge simply does not rename, while <code>MergePreviewRequest</code> fails only because its cross-field validator then finds neither source of truth — an accident, not a policy. The API-side question is #177.</li> <li><strong>Three things the issue did not know.</strong> <code>SubjectExclusionRequest.source</code> has a <code>HUMAN</code> default, so the client's "required by the contract, and deliberately so" was false — the client now requires it by a derived <code>Omit</code>/<code>Required<Pick<>></code>, and the comment says why. <code>NerExclusionRequest</code> is shared by the <code>POST</code> and the <code>DELETE</code>, whose handler reads only <code>data.words</code>, the exact smell <code>SubjectExclusionRemovalRequest</code> already documents — filed as #176. And <code>previewSubjectExclusions</code> had no caller since it was born in <code>32d19f1</code>: measured, the only unused export among 130, so the client function and its two response types are gone and the intent lives in #175.</li> <li><strong>Verified, and what was not.</strong> The gate was proved by mutation, not by passing: putting one body back inline fails both rules, deriving a body from another route's model fails the second, and adding a narrowing outside the table fails the third; the file was restored byte-identical afterwards. <code>tsc</code>, ESLint, the Vite build, <code>ruff check</code>/<code>format --check</code> and <code>basedpyright</code> answer zero, and <code>uv run pytest testing/unit</code> answers <strong>1044 passed, 1 skipped</strong>. <strong>Not looked at in a browser, and nothing to look at</strong>: the emitted bundle is byte-identical to the pre-change one (<code>dist/assets/index-DIQDkiK4.js</code>, sha256 <code>21ca64dc0b02571364431c9c52300079d69fc236c8d03b54a06ddc015dd0b75c</code>), because a type-level change erases at compile time — so no screen's pixels can differ, and no rendering would show the change.</li> </ul> <h2 id="2026-10-24-the-ner-removal-declares-only-what-it-reads-176">2026-10-24 — the NER removal declares only what it reads (#176)<a class="headerlink" href="#2026-10-24-the-ner-removal-declares-only-what-it-reads-176" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>77bca6b..2e0e9b9</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the page was reported stale through the contract and the taxonomy controller, and the paragraph it owns (<em>Exclusões de NER</em>) names the reversal as a plain <code>DELETE</code> that re-opens the term, never as a body. The removal's model is not described there, and the screen's call sends <code>{words: [word]}</code> either way, so the text still holds.</li> <li><strong>Why the round exists:</strong> the <code>DELETE</code> on <code>/entities/ner-exclusions</code> took the ban's <code>NerExclusionRequest</code> and read only <code>data.words</code>, so a <code>reason</code> answered 200 and vanished; the client had narrowed it back with <code>Pick<NerExclusionRequest, "words"></code>. #176 gave the removal its own model, <code>SubjectExclusionRemovalRequest</code>'s twin, and the narrowing went with it.</li> <li><strong>What the round measured.</strong> The contract diff is the new schema and the <code>$ref</code> of the one operation, nothing else: <code>bun run contract</code> emits <code>NerExclusionRemovalRequest</code> with <code>words</code> and <code>minItems: 1</code>, and the generated client's signature changes from the narrowing to the model. The behaviour is pinned by <code>test_remove_ner_exclusions_refuses_the_reason_the_route_ignores</code> (a <code>reason</code> is a <strong>400</strong>, and the service is not called) and by the table in <code>testing/unit/curator/test_api_body_shapes.py</code>, which now lists <strong>three</strong> narrowings instead of four. <code>uv run pytest testing/integration/api/controller/test_taxonomy_controller.py</code> answers <strong>63 passed</strong> against the test database; <code>testing/unit/api</code>, <code>testing/unit/curator</code> and <code>testing/unit/docs</code> answer <strong>147 passed</strong>; <code>ruff check</code>, <code>ruff format --check</code> and <code>basedpyright</code> answer zero, and <code>tsc</code>/ESLint are green.</li> <li><strong>Not verified, and said so.</strong> No screen was rendered: the change is a request model and a generated type, and the screen behind it sends the same body before and after. The API was not driven by hand against the reference collection either — the 400 is the integration test's client.</li> </ul> <h2 id="2026-10-24-the-refusal-comes-before-the-write-175">2026-10-24 — the refusal comes before the write (#175)<a class="headerlink" href="#2026-10-24-the-refusal-comes-before-the-write-175" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>2e0e9b9..83f7d39</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the <em>Não é assunto</em> section said the preview route "exists as an API read — no screen calls it". It now describes the box asking the route as the typing settles (250 ms), what the two lines on the screen mean, why a partial refusal leaves the button enabled, and that the route takes only the terms.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph, same structure.</li> <li><strong>Why the round exists:</strong> #175. The plan route has existed since #83, and the client function that read it had none of the 130 callers and was deleted in #93; the screen read <code>blocked</code> from the write's answer instead, so the refusal arrived after the click. The route also declared the write's <code>SubjectExclusionRequest</code> and read only <code>data.words</code> — #176's smell in a second place, which this round fixed with <code>SubjectExclusionPreviewRequest</code> rather than adding a narrowing to the client.</li> <li><strong>What the round measured.</strong> The contract diff is the new schema and the one operation's <code>$ref</code>. The behaviour is pinned by <code>test_the_subject_exclusion_preview_declares_only_the_terms</code> — the preview refuses a <code>reason</code>/<code>source</code> body with <strong>400</strong> — and by the existing dry-run test, which sends <code>{"words": [...]}</code> and still reads the same plan. <code>pytest</code> over the tag catalogue and the body-shape gate: <strong>20 passed</strong>; <code>tsc</code>, ESLint, <code>ruff check</code>/<code>format --check</code> and <code>basedpyright</code> answer zero.</li> <li><strong>What was rendered.</strong> <code>.render/subject-exclusions-preview.png</code> and <code>.render/subject-exclusions-blocked.png</code>: the built SPA served by the API (<code>create_app()</code> mounts <code>dist</code>) against <code>scrinalia_zeroed</code>, with a session row inserted for the round and deleted after it, driven over CDP. With <code>capejo, vista aerea</code> in the box the screen reads, <strong>before any click</strong>, <code>1 termo(s) já estão numa categoria de assunto e não serão registrados: capejo (Mobilidade e Transporte)</code> beside <code>1 termo(s) serão registrados e banidos de voltar a virar tag.</code>, and the button stays enabled; with <code>capejo</code> alone only the refusal line renders and the button is <strong>disabled</strong> (CDP reported <code>disabled=true</code> and the pixels show it greyed).</li> <li><strong>Not verified, and said so.</strong> No write was performed from the screen: the ban would have recorded a human verdict in the reference collection for the sake of a screenshot, and the write path is covered by the integration suite. The screen was read on <code>scrinalia_zeroed</code> only (101 descriptions, 139 exclusions, 83 tags in drawers), not on the large collection.</li> </ul> <h2 id="2026-10-24-the-stamp-breakdown-says-which-ledger-it-counts-150">2026-10-24 — the stamp breakdown says which ledger it counts (#150)<a class="headerlink" href="#2026-10-24-the-stamp-breakdown-says-which-ledger-it-counts-150" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>83f7d39..55fda25</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: <em>What each queue means</em> gains the paragraph that separates the four numbers from <code>pending</code> — the breakdown is over the worker's whole ledger, and the reference collection's NER card reads 4.831 stamped against 4.813 pending, the difference being the 18 descriptions a human approved.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same paragraph.</li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the workers screen opens four facts now, with the queue and the ledger apart, and the page points at the operate guide for what each one counts.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same sentence.</li> <li><strong>Why the round exists:</strong> #150. The placement read as a decomposition of the queue — "4.813 pending beside 0 + 1.650 + 3.181" — and the 18 units that are not pending had nowhere to be seen. #137 had added <code>stamp_breakdown</code> and the card rendered it under <code>Fila</code>; the number was right and the scope was not.</li> <li><strong>What the round measured.</strong> The difference is not arithmetic in the abstract: on the collection the render used, <code>select review_status, count(*)</code> gives <strong>18</strong> <code>HUMAN_APPROVED</code> and no document without text in the extracted columns, so <code>4.831 − 18 = 4.813</code> is <code>ai_writable_documents()</code> doing exactly what it promises. The contract diff is the description of one field (<code>stamp_breakdown</code>), with the component untouched (the field node carries the description, not the <code>WorkerStampBreakdownDTO</code>).</li> <li><strong>What was rendered.</strong> <code>.render/workers-150.png</code>: the built SPA served by the API against a <strong>clone</strong> of the large collection (<code>scrinalia_150</code>, <code>CREATE DATABASE ... TEMPLATE memoriacuritibana</code>, then <code>alembic upgrade head</code> — two additive migrations — dropped after the round), with a session row inserted and deleted for it, driven over CDP. The NER card, expanded, reads <strong>FILA (DESCRIÇÕES) 4.813</strong> with "o predicado da fila: o que a próxima execução leria. Não é o ledger do worker." beside <strong>CARIMBOS (DESCRIÇÕES) 4.831</strong> with "0 com produtor · 1.650 sem produtor conhecido · 3.181 sem registro — o ledger inteiro do worker, não a fila." The four facts fit one row at 1280px (<code>sm:grid-cols-2 xl:grid-cols-4</code>).</li> <li><strong>Not verified, and said so.</strong> The card was read on the cloned collection only, and no worker was run: the ledger numbers are the clone's own state, not a fresh run's. The four non-stamp workers' <code>Processados</code> fact was not opened in pixels — the NER card is the stamp case the issue is about.</li> </ul> <h2 id="2026-10-24-the-clusterings-columns-are-a-read-174">2026-10-24 — the clustering's columns are a read (#174)<a class="headerlink" href="#2026-10-24-the-clusterings-columns-are-a-read-174" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>c71f3aa..5900c48</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <em>Descobrir Categorias</em> gains the picker — the read route, why <code>final_title</code> is in the corpus and out of the default, and the three refusals (unknown column, empty list, list with <code>source_type=tags</code>).</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the report named it through <code>document_repo.py</code>, where the four-column fallback became <code>CLUSTERING_TEXT_COLUMNS</code>. The value is the same four columns and the page never described them; what the clustering reads is the curation page's subject now.</li> <li><strong>Why the round exists:</strong> #174. The route accepted <code>columns_to_extract</code> since it existed and no screen sent it; #93 narrowed the client to <code>Pick<SuggestMacroRequest, "source_type"></code> rather than advertise a knob nothing set. The other half was a second definition: the repository fell back to a literal list of four while <code>AI_TEXT_COLUMNS</code> has five, and an unknown name reached <code>getattr(ArchiveDocument, column)</code> for a <strong>500</strong>.</li> <li><strong>What the round measured.</strong> The contract grows by one operation and one schema (<code>ClusteringColumnsResponse</code>, 45 lines in <code>schema.d.ts</code>). <code>CLUSTERING_TEXT_COLUMNS</code> keeps the same four values, so no behaviour changed for a caller that omits the field; the unit test pins that the difference from <code>AI_TEXT_COLUMNS</code> is exactly <code>final_title</code>. The integration tests answer <strong>41 passed</strong> over the tag catalogue, the text-quality domain and the body-shape gate — including the 400 that names <code>original_titel</code>, the 400 for <code>source_type=tags</code>, and the 400 for <code>[]</code>.</li> <li><strong>What was rendered.</strong> <code>.render/discover-columns.png</code>, <code>.render/discover-off.png</code> and <code>.render/discover-send.png</code>: the built SPA served by the API against <code>scrinalia_zeroed</code>, with a session row inserted for the round and deleted after it, driven over CDP. Seen in the pixels: the five checkboxes with the four defaults marked <code>(padrão)</code> and <code>final_title</code> unchecked; every box unchecked disables <em>Propor Categorias</em> and prints <code>Escolha ao menos uma coluna.</code>; and with the five checked the request the screen sent was, read off the network event, <code>{"source_type":"documents","columns_to_extract":["original_title","admin_bio_history","provenance","scope_content","final_title"]}</code>.</li> <li><strong>Not verified, and said so.</strong> The clustering itself was <strong>not run to completion</strong>: the browser was closed as soon as the request was captured, so BERTopic never answered and no proposal was seen on the screen. The run's use of the columns rests on the service test that already asserted <code>fetch_documents_for_clustering(columns_to_extract=...)</code> receives them.</li> </ul> <h2 id="2026-10-24-a-request-body-refuses-what-it-does-not-know-177">2026-10-24 — a request body refuses what it does not know (#177)<a class="headerlink" href="#2026-10-24-a-request-body-refuses-what-it-does-not-know-177" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>5900c48..4a8b78f</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: the report named it through <code>system_schema.py</code>, whose two request models now inherit <code>RequestModel</code>. The page documents the <em>options</em> contract — a key the worker does not declare is refused inside <code>options</code> — and this change is about an unknown key at the top level of a body, which the page never said was accepted.</li> <li><strong>Why the round exists:</strong> #177, with the policy the owner chose (forbid everywhere). The body position was loud only where a model happened to say so: 22 of the 53 bodies declared <code>extra="forbid"</code> and 31 kept pydantic's default, so the same stale client answered 400 on one route and 200 on the next.</li> <li><strong>What the round measured.</strong> The generated contract is <strong>unchanged</strong>: <code>additionalProperties</code> was already absent for the 22 strict models, so Litestar never expressed the difference, and adding <code>extra="forbid"</code> to the other 31 emits nothing either — measured here by regenerating <code>openapi.json</code> and <code>schema.d.ts</code> and getting a <strong>0-line</strong> diff. The gate is <code>testing/unit/api/test_request_models.py</code>: it resolves the bodies from the <strong>built</strong> application (55 of them, exactly the names the contract points at from a <code>requestBody</code>), and fails both on a body model that does not forbid and on a walk that stops finding them. The full suite answers <strong>2041 passed, 1 skipped</strong>; <code>basedpyright</code>, <code>ruff check</code> and <code>ruff format --check</code> answer zero.</li> <li><strong>What the policy changed in behaviour, and the two tests that said otherwise.</strong> Two calls used to be accepted and silently ignored: a <code>role</code> in the setup body (<code>test_the_client_cannot_choose_the_role</code> asserted 201 and ADMIN) and a <code>requested_by</code> in the run trigger. Both are <strong>400</strong> now, and the tests say so — the first also checks that the installation is still unconfigured after the refusal. A third test was added where the policy is a security property: a <code>changed_by</code> in the materialisation body is refused, because the author is the session's.</li> <li><strong>A finding, not fixed here.</strong> <code>HierarchyMaterialisationRequest</code> exists twice: in <code>api/schemas/hierarchy_requests.py</code> (the body: <code>note</code>, <code>limit</code>) and in <code>domains/archive/schemas/hierarchy_schema.py</code> (the command: the same plus <code>changed_by</code>). The handler spreads the body and adds the session author into the command. They answer different questions, so the duplication is a name collision rather than a second definition — renaming would move a contract component and belongs to a round of its own.</li> <li><strong>Not verified, and said so.</strong> No screen was rendered: no route changed shape and the SPA sends bodies derived from the contract, so the pixels cannot differ. The 53 bodies were not exercised against a live server one by one — the gate reads the policy, and the suite drives the routes.</li> </ul> <h2 id="2026-10-25-the-materialisation-command-has-its-own-name-184">2026-10-25 — the materialisation command has its own name (#184)<a class="headerlink" href="#2026-10-25-the-materialisation-command-has-its-own-name-184" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>4a8b78f..74a3a5c</code></li> <li><strong>Pages:</strong> <strong>none changed by this round</strong>, and the two the report named are the merge's trace:</li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the report named it through <code>a24154e</code>, the merge of the #174 branch, whose page text was written and triaged in that round (<code>c71f3aa..5900c48</code>). No declared source moved after it — the change here is a class name in the domain schemas, which the page never prints.</li> <li><code>guides/curate.pt.md</code> — <strong>no-change</strong>: the same merge, and the translation was updated with the English page in the #174 round.</li> <li><strong>Why the round exists:</strong> #184, the finding #177 recorded. Two models shared the name <code>HierarchyMaterialisationRequest</code> — the HTTP body (no author, the one the contract <code>$ref</code>s) and the service command (<code>changed_by</code>); the controller imported both, the second as <code>MaterialisationCommand</code>, and the docstrings had drifted by a word.</li> <li><strong>What the round measured.</strong> The rename moved <strong>22 references in 4 files</strong> — the domain schema, the package <code>__init__</code>, the service and the controller (whose alias disappeared) — plus 12 constructions in the service's integration test, and <strong>zero</strong> lines of contract: regenerating <code>openapi.json</code> and <code>schema.d.ts</code> leaves both byte-identical, because the command is not bound to a route. The gate is <code>testing/unit/quality/test_model_names.py</code>: of the <strong>276</strong> pydantic models in the three schema packages, the duplicate was the only one, and the test fails with the two module names when one is reintroduced — proved by mutation (a duplicate class appended to <code>hierarchy_requests.py</code> made it fail; the file was restored byte-identical). Full suite <strong>2044 passed, 1 skipped</strong>; <code>basedpyright</code>, <code>ruff check</code>/<code>format --check</code> and <code>tsc</code>/ESLint green.</li> <li><strong>Not verified, and said so.</strong> No screen was rendered — the rename touches no route and no generated type, so no pixel can differ. The materialisation routes were exercised only through their integration tests, not against a live server.</li> </ul> <h2 id="2026-10-25-the-workers-leave-the-api-adr-0014">2026-10-25 — the workers leave the API (ADR 0014)<a class="headerlink" href="#2026-10-25-the-workers-leave-the-api-adr-0014" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>74a3a5c..27063f7</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0014-the-workers-leave-the-api.md</code> — <strong>created</strong></li> <li><code>adr/0004-worker-execution-from-the-api.md</code> — <strong>updated</strong> (superseded)</li> <li><code>adr/index.md</code> — <strong>updated</strong></li> <li><code>guides/install.md</code> / <code>install.pt.md</code> — <strong>updated</strong>: the runner and the API; the network and the credential; the GPU host; the compose <code>runner</code> service.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the runner; interrupted runs by lease; the queue in the ledger; the panel's runners and availability; the limits.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: the request/runner/lease columns and <code>archive_worker_runners</code>.</li> <li><code>README.md</code> — <strong>updated</strong>: the runner, the image and <code>bun run dev</code>.</li> <li><code>SECURITY.md</code> — <strong>updated</strong>: the runner's database reachability replaces the single-process bullet.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>no-change</strong>: the report names them through the release's version declarations; the curation contract and the screens did not move — the panel's new fields are additive and belong to operate.</li> <li><strong>Why the round exists:</strong> #42, milestone 1.6: move the workers out of the API behind a runner and a lease, before the retry (#43) and cancellation (#72) policies.</li> <li><strong>What the round measured.</strong> Six commits: the ADR; the queue columns/table/indexes; the runner credential; the claim/lease/run; the API only enqueueing; the panel. The migration <code>a842f9efad08</code>, and <code>alembic check</code> reports no drift. Full suite <strong>2079 passed, 1 skipped</strong>; <code>basedpyright</code>, <code>ruff</code>, <code>tsc</code>, ESLint and the Vite build green. Rendered and looked at: the workers panel (an online runner with two capabilities, <code>sem runner</code> on the seven others, <code>Enfileirar execução</code>) and the run history (a live run enqueued by the panel, claimed and closed by a runner, showing <code>runner</code>, <code>sinal</code> and <code>lease</code>). The run was #9, <code>SUCCESS</code>, claimed <strong>0.46 s</strong> after the enqueue.</li> <li><strong>Not verified, and said so.</strong> The GPU host is not this machine: the runner rendered is the CPU one and the CUDA path is the install guide's, not a measured run. A real remote database over TLS/tunnel was not exercised; the <code>RUNNER_DATABASE_URL</code>/<code>DB_SSLMODE</code> path is tested against the local database only. #43/#72 are vocabulary in the ADR, not code. The API with several processes was not run; the claim/lease tests use two runners on one database.</li> </ul> <h2 id="2026-10-25-schedules-and-retries-adr-0015">2026-10-25 — schedules and retries (ADR 0015)<a class="headerlink" href="#2026-10-25-schedules-and-retries-adr-0015" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>27063f7..692de01</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0015-schedules-and-retries.md</code> — <strong>created</strong>: the agenda, the scheduler, the retry and the slot.</li> <li><code>adr/index.md</code> — <strong>updated</strong>.</li> <li><code>guides/install.md</code> / <code>install.pt.md</code> — <strong>updated</strong>: the <code>DEFAULT_TIMEZONE</code> row, the scheduler process, the compose service.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the "no scheduler" section replaced by "Scheduling and retries"; the agenda on <code>/configuracoes/workers</code> and <code>/sistema/workers</code>; the retry policy and the slot; the limits.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: <code>archive_worker_schedules</code> and the <code>attempt</code>/<code>retry_of</code>/<code>not_before</code> columns.</li> <li><code>README.md</code> — <strong>updated</strong>: the scheduler and the retry.</li> <li><code>AGENTS.md</code> — <strong>updated</strong>: the scheduler command and the dev script (PATCH 1.2.2, 15,998 B).</li> <li><code>.dsh/skills/workers</code>, <code>.dsh/skills/data</code> — <strong>updated</strong>: the scheduler/retry and the 34 settings.</li> <li><strong>Why the round exists:</strong> #43, milestone 1.6: the scheduling and the retry ADR 0014 deferred.</li> <li><strong>What the round measured.</strong> Six commits: the ADR 0015; the schedule table and the attempt columns; the scheduler (the advisory lock, the tick, the skip, the catch-up, the cron's DST); the retry and the API (the routes, the DTOs, the retry as a new row); the panel; the dev/deploy. The migration <code>b7e4c2a9f1d0</code>, and <code>alembic check</code> reports no drift. Full suite <strong>2124 passed, 1 skipped</strong>; <code>basedpyright</code>, <code>ruff</code>, <code>tsc</code>, ESLint and the Vite build green; <code>mkdocs --strict</code> green. Rendered and looked at: the agenda in <code>/configuracoes/workers</code> (the <code>*/1 * * * *</code> cron, <code>America/Sao_Paulo</code>, the enabled state and the next run), the <strong>agenda</strong> badge and fact in <code>/sistema/workers</code> (with <code>sem runner</code> on the seven workers the runner does not accept) and four <code>SCHEDULE</code> runs in <code>/sistema/execucoes</code>, claimed by <code>render:9055ae26</code>.</li> <li><strong>Not verified, and said so.</strong> The retry's live path was not rendered: the render's cleaning run succeeds, so "tentativa 2/3" and "nova tentativa de #N" are covered by the integration tests, not by a screenshot. The advisory lock's standby/failover was tested with two services on one test database, not observed across two hosts. A GPU host or a multi-host deploy was not exercised.</li> </ul> <h2 id="2026-10-25-a-run-can-be-stopped-from-the-panel-72">2026-10-25 — a run can be stopped from the panel (#72)<a class="headerlink" href="#2026-10-25-a-run-can-be-stopped-from-the-panel-72" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>57c58fd..b84cad3</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the ledger columns and the <code>CANCELLED</code> status, the new <strong>Cancelling a run</strong> section (the request, the two outcomes, the batch boundary, the CLI signals and the daemon's <code>INTERRUPTED</code>), the route and screen tables, the retry note and the limits. The trigger list now names <code>SCHEDULE</code>, which the #43 round left out.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: <code>cancel_requested_at</code>, <code>cancel_requested_by</code>/<code>_user_id</code> and the <code>CANCELLED</code> value on <code>archive_worker_runs</code>.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the Execuções screen's one write, with the confirmation and the honest granularity.</li> <li><code>.dsh/skills/workers</code> — <strong>updated</strong>: the cancellation is a request, <code>RunControl.checkpoint</code>, the source gate, the <code>BaseException</code> choice and the CLI/daemon split.</li> <li><code>adr/0014</code> — <strong>no-change</strong>: decision 9 fixed this vocabulary and named #72; the ADR records the decision, and this round is its implementation.</li> <li><strong>Why the round exists:</strong> #72, milestone 1.6 — the panel could start work it could not stop, and the only lever was <code>docker compose stop</code>.</li> <li><strong>What the round measured.</strong> Four code commits: the ledger (the enum value, the two columns with their author link, the migration <code>d72c0a4b1e9f</code>, the DTO and the repository); the runner (the one <code>RunControl.checkpoint</code>, the six loop checks, the CLI signal handler, the service use case); the API (the route, the two codes, the generated contract); the SPA (the status mirror and the row action). Full suite <strong>2146 passed, 1 skipped</strong>; <code>basedpyright</code>, <code>ruff</code>, <code>tsc</code>, ESLint and the Vite build green; <code>alembic upgrade head</code> and <code>alembic check</code> report no drift; <code>mkdocs --strict</code> green.</li> <li><strong>What was rendered.</strong> <code>.render/72-runs.png</code> shows the three states together: the <code>RUNNING</code> <code>embedding</code> row with "Cancelamento pedido em 25/10/2026, 14:05 por Cassio…", the <code>CANCELLED</code> <code>thumbnail</code> row, and the <code>QUEUED</code> <code>typology</code> row with <strong>Cancelar execução…</strong>. <code>.render/72-confirm.png</code> shows the inline confirmation and its sentence; <code>.render/72-cancelled.png</code> shows the <code>typology</code> row after the browser clicked <strong>Confirmar cancelamento</strong> — the POST ran through the real route and the row came back <strong>Cancelada</strong>. The <code>RUNNING</code> row was claimed by <code>render:demo</code> with a direct <code>UPDATE</code> (no worker process), and the request was recorded through the API; the scratch session row and the three runs were deleted after the round.</li> <li><strong>Not verified, and said so.</strong> The batch-boundary stop of a real model run was not observed: the checkpoint is covered by the integration tests with a double, not by a spaCy/torch run. The CLI's signal handler is unit-tested as a callable, but no <code>SIGTERM</code> was delivered by the operating system to a process mid-batch. The daemon's <code>SIGTERM</code> → lease → <code>INTERRUPTED</code> path was not exercised.</li> </ul> <h2 id="2026-10-25-the-origin-is-a-first-class-object-44">2026-10-25 — the origin is a first-class object (#44)<a class="headerlink" href="#2026-10-25-the-origin-is-a-first-class-object-44" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>b84cad3..8e0c42f</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0016-the-origin-is-a-first-class-object.md</code> — <strong>created</strong>: the decision (definition in code + lifecycle row, <code>origin_code</code> as provenance, the <code>ingestion</code> stage in the same ledger, the manual entry as <code>PENDING_AI</code>, the deferred CSV and per-origin AI settings).</li> <li><code>adr/index.md</code>, <code>mkdocs.yml</code> — <strong>updated</strong>: the new ADR is linked and in the nav.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: ten workers with <code>ingestion</code> first, the origin table and screen, the manual entry, the per-origin slot and agenda, the removed <code>ACERVO_SOURCE</code>, the origin routes, the <code>origin</code> filter on the ledger and the <code>ingestion</code> row that links to the origin screen.</li> <li><code>guides/install.md</code> / <code>install.pt.md</code> — <strong>updated</strong>: the origin is a row, not a setting; <code>PUBLIC_SCRAPE_*</code> is the fallback for <code>pmc</code>; the migration seeds <code>pmc</code> and <code>manual</code>; the upgrade path is <code>/sistema/ingestao</code>.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: <code>ingestion_origins</code>, <code>origin_code</code> on the queue/raw/staging/archive/run ledger, <code>created_by</code> on <code>raw_data</code>, and the provenance-not-key rule.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the <code>/sistema/ingestao</code> screen, the worker row that links to it (the generic run button cannot name an origin) and the manual form — data entry, not an approval; the AI queue sees the record; the operator controls are hidden for a curator.</li> <li><code>README.md</code> — <strong>updated</strong>: the ten worker names, the ingestion screen and the origin agenda.</li> <li><code>.env.example</code> — <strong>updated</strong>: <code>ACERVO_SOURCE</code> removed; <code>PUBLIC_SCRAPE_*</code> documented as the reference adapter's fallback.</li> <li><code>.dsh/skills/ingestion</code> — <strong>updated</strong>: the origin object, per-record schema, the <code>ingestion</code> stage and the manual origin; the old <code>ACERVO_SOURCE</code> rule removed.</li> <li><code>.dsh/skills/workers</code> — <strong>updated</strong>: the <code>ingestion</code> spec and the per-origin slot.</li> <li><code>.dsh/skills/data</code> — <strong>updated</strong>: 33 settings fields, no <code>ACERVO_SOURCE</code>, the origin is a row.</li> <li><strong>Why the round exists:</strong> #44, milestone 1.6 — the origin was a parameter with one selector and no lifecycle, no agenda and no attribution; and the ingestion was the one stage the panel could not answer for.</li> <li><strong>What the round measured.</strong> Full suite <strong>2164 passed, 1 skipped</strong>; <code>basedpyright</code> <strong>0 errors</strong>; <code>ruff check</code> and <code>ruff format --check</code> green; <code>tsc</code>, ESLint and the Vite build green; <code>alembic upgrade head</code> applied <code>44a1b2c3d4e5</code> and <code>alembic check</code> reports no drift; <code>mkdocs build --strict</code> green. The generated contract was regenerated and matches the app.</li> <li><strong>What was rendered.</strong> <code>.render/44-ingestao.png</code> is the screen with the manual form and both origin cards; <code>.render/44-ingestao-config.png</code> is the PMC configuration panel (URLs and delay); <code>.render/44-ingestao-agenda.png</code> is its agenda (cron, IANA zone, save/remove); <code>.render/44-ingestao-submit.png</code> is a <strong>real</strong> submit: the form filled, the POST ran, and the success notice returned the manual id and "a transferência foi enfileirada". The record was staged and the transfer row was queued; the scratch rows and the temporary account were removed after the round.</li> <li><strong>Not verified, and said so.</strong> No runner was online, so the queued transfer was not executed by a runner in the browser; the archive <code>PENDING_AI</code> and the NER queue are covered by the integration test (<code>test_manual_ingestion_stages_the_record_and_queues_the_transfer</code>), which runs the transfer directly. A real scraper execution of the <code>ingestion</code> worker against the reference site was not run, and the scheduler's origin tick was tested against the test database, not observed in a live process. The CSV import (#191) and the full manual field set (#192) are not in this change.</li> </ul> <h2 id="2026-10-25-the-ingestion-panel-stops-reporting-a-total-49">2026-10-25 — the ingestion panel stops reporting a total (#49)<a class="headerlink" href="#2026-10-25-the-ingestion-panel-stops-reporting-a-total-49" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>8e0c42f..e1362e1</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the <code>ingestion</code> row in <code>/sistema/workers</code> has no aggregate pending/processed/failed counters — those are per origin on <code>/sistema/ingestao</code>; the health card reads <code>ingestion_origins</code> (active/total and each origin's label, with a link), and the process card labels <code>PUBLIC_SCRAPE_*</code> as the environment fallback it is. The health route and screen rows name the new summary.</li> <li><code>.dsh/skills/workers</code> — <strong>updated</strong>: the <code>ingestion</code> spec has no aggregate counters; the row is a pipeline index.</li> <li><code>.dsh/skills/observability</code> — <strong>updated</strong>: the health probe reads the origin table, not the env flag, and the queue stays on the ingestion screen.</li> <li><strong>Why the round exists:</strong> #49 was written before <code>/sistema/ingestao</code>; #44 delivered the intent, but left a row whose <code>processed</code>/<code>failed</code> were record totals under an "origens" label, and a health flag that could say "not configured" with the origin row configured. The round repurposes #49 for those two truthfulness defects instead of duplicating the origin screen.</li> <li><strong>What the round measured.</strong> Full suite <strong>2167 passed, 1 skipped</strong>; <code>basedpyright</code> 0 errors; <code>ruff check</code> and <code>ruff format --check</code> green; <code>tsc</code>, ESLint and the Vite build green; the API contract was regenerated and matches; <code>mkdocs build --strict</code> green. Rendered with the dev database and a temporary admin: <code>.render/49-health.png</code> shows "2 de 2 origens ativas", both labels and the <strong>Abrir ingestão</strong> link, and the process card says "fallback de coleta no .env presente"; <code>.render/49-workers.png</code> shows the <code>ingestion</code> row with <strong>Abrir ingestão</strong>, no aggregate counters.</li> <li><strong>Not verified, and said so.</strong> The <strong>Abrir ingestão</strong> link was rendered and typechecked, but was not followed in the browser this round; <code>/sistema/ingestao</code> itself was rendered in the #44 round. The health probe's database-down path is a unit test with a double, not a stopped database. No new migration: the table and columns are the #44 ones.</li> </ul> <h2 id="2026-10-25-the-description-thumbnail-reaches-the-screen-102">2026-10-25 — the description thumbnail reaches the screen (#102)<a class="headerlink" href="#2026-10-25-the-description-thumbnail-reaches-the-screen-102" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>89b2ec6..ed52347</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0017-the-thumbnail-reaches-the-browser-through-the-api.md</code> — <strong>created</strong>: the delivery decision (an authenticated proxy route, a <code>thumbnail_url</code> derived on the read view, 404/503 for the two failure states, the public route deferred to #46) and the rejected presigned, redirect and public-read alternatives.</li> <li><code>adr/index.md</code>, <code>mkdocs.yml</code> — <strong>updated</strong>: the new ADR is linked and in the nav.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the dossier's media card, the list's 56 px miniature, the three states ("sem imagem", "indisponível", the image), the route and its <code>thumbnail_url</code>, and the public projection still carrying the raw <code>s3://</code> reference (#46).</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the worker writes the object; the API serves it on demand, so the endpoint must stay reachable from the API and allow <code>GetObject</code>.</li> <li><strong>Why the round exists:</strong> #102 — the <code>thumbnail</code> worker had filled 97 of the 101 reference descriptions and no screen read the field; the browser cannot resolve an <code>s3://</code> URI, and the endpoint is the API's private one (<code>.env</code> says <code>http://localhost:8333</code>, the compose file says <code>http://host.docker.internal:9000</code>). The issue also asked for a documented decision between a presigned GET and a proxy, and for a screen that still renders when the object is gone.</li> <li><strong>What the round measured.</strong> Full suite <strong>2187 passed, 1 skipped</strong>; <code>basedpyright</code> <strong>0 errors</strong>; <code>ruff check</code> and <code>ruff format --check</code> green; <code>tsc</code>, ESLint and the Vite build green; the API contract was regenerated and matches the app; <code>mkdocs build --strict</code> green. The 101 descriptions yield <strong>97</strong> with a <code>storage_thumbnail_uri</code> and <strong>4</strong> with neither a stored URI nor a source image; one object sampled in the local bucket is <strong>384×249, 119,702 bytes, image/jpeg</strong>.</li> <li><strong>What was rendered.</strong> <code>.render/102-built-dossie-imagem.png</code> is the dossier with a real stored image; <code>.render/102-built-dossie-sem-imagem.png</code> is description 2731, one of the four without a source image; <code>.render/102-built-dossie-indisponivel.png</code> is description 1000, whose URI has no object in the bucket and therefore answers 404; <code>.render/102-built-lista.png</code> is the collection list with miniatures and the same two placeholders. All four are the <strong>built</strong> SPA served by the API at <code>:8000</code>, rendered in the headless shell at 1280×1000, with a temporary session row that was removed afterwards.</li> <li><strong>Not verified, and said so.</strong> The bucket used for the render had 40 of the 97 objects (copied from the old <code>memoria-curitibana-bronze</code> volume into <code>meu-bucket-teste</code>); the other 57 answered 404 and are represented by the unavailable state, not by a success. The 503 path is an integration test with a doubled reader, not a stopped endpoint observed in the browser. The mobile stack below <code>sm</code> was <strong>not</strong> rendered; the evidence there is the classes. The public route is #46 and was not implemented, so <code>PublicDocumentSummary</code> still publishes the raw <code>s3://</code> URI. No migration.</li> </ul> <h2 id="2026-10-25-a-trial-before-committing-the-collection-and-the-gabarito-it-reads-71">2026-10-25 — a trial before committing the collection, and the gabarito it reads (#71)<a class="headerlink" href="#2026-10-25-a-trial-before-committing-the-collection-and-the-gabarito-it-reads-71" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>984b514..06bada0</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0018-trials-and-the-human-gabarito.md</code> — <strong>new</strong>: a trial is a ledger row (<code>trial</code>, <code>trial_result</code>) that runs two to four configurations over one sample and writes no archival content; the gabarito is the human decision already recorded per axis — the latest <code>archive_document_revisions</code> row that changed <code>typology_id</code>, <code>curated_macro_category</code>, <code>origin=HUMAN</code>, the conflict resolution — and <strong>never <code>HUMAN_APPROVED</code> alone</strong>; the sample is the verdict set plus a seeded ai-writable sample; the comparison produces the settings write. Index and <code>mkdocs.yml</code> nav updated.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: "Comparing configurations on a sample" (the trial, the two populations, the save/run flow), the two new routes and the <code>trial</code> filter in the operations table, the <code>ensaio</code> badge and the comparison card in the screens table, and <code>trial</code>/<code>trial_result</code> in the run ledger.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: the <code>archive_worker_runs</code> row carries <code>trial</code> and <code>trial_result</code> (JSONB).</li> <li><code>.dsh/skills/workers</code> — <strong>updated</strong>: the trial is a run kind, the metric lives in the package, the gabarito rule, and the counter options now come from <code>configuration.counter_options</code>.</li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the hook flags it because the SPA routes and the contract it declares moved, but the comparison card lives on <code>/configuracoes/workers</code>, the operator screen the operations guide owns, and it needs <code>OPERATE</code>; no curation workflow changed.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: the runner daemon's flags and the deploy are unchanged; the trial is dispatched from a claimed row, not from a new installation step.</li> <li><strong>Why the round exists:</strong> #70 made a classified description re-classifiable, so a configuration can be re-run; #71 answers the question before the run — <em>which configuration is better on this collection, and what does it cost?</em> The decision the issue demanded first, which screen owns the human verdict and how it relates to <code>HUMAN_APPROVED</code>, is in ADR 0018: the verdict is the decision the dossier, the tags screen or the conflicts screen already recorded; approving a record is not certifying its labels. The plan, the metric and the bench now share one definition (<code>domains/archive/evaluation/metrics.py</code>), and the worker calls the same <code>link_decision</code>.</li> <li><strong>What the round measured.</strong> Full suite <strong>2197 passed, 1 skipped</strong> on the rebased branch (test database on 5434, <code>postgresql+psycopg2</code>); <code>basedpyright</code> 0 errors; <code>ruff check</code> and <code>ruff format --check</code> green; the contract was regenerated with <code>bun run contract</code> and the SPA's <code>tsc</code>, ESLint and Vite build are green; <code>mkdocs build --strict</code> green; <code>alembic upgrade head</code> applied <code>71a2b4c6d8e0</code> and <code>alembic check</code> reports no drift. Rendered with the headless shell over CDP: <code>.render/71-trial-table.png</code> is the comparison card of a finished trial (the side-by-side table, the <code>melhor concordância</code> badge and the disabled <strong>Rodar no acervo inteiro</strong>), <code>.render/71-ledger-ensaio.png</code> is the ledger row with the <code>ensaio</code> badge and the link back to the settings, and <code>.render/71-settings-trial.png</code> is the settings screen with the card in place.</li> <li><strong>Not verified, and said so.</strong> The trial was <strong>not run against a real model</strong>: the rendered result is a scratch <code>archive_worker_runs</code> row (and a scratch session) inserted into the dev database and deleted after the round, so the numbers in the screenshot are a fixture, not a measurement of the reference collection; the screen's <strong>Rodar ensaio</strong> was not clicked. The route is covered by the controller test, the executor by unit tests with a fake engine, and the daemon dispatch by an integration test — but the real cost per document on this machine is unmeasured, and no typology preset was chosen from it. The comparison card's editors start from the current settings, not from the last trial's configurations; repeating a measured comparison means re-entering it.</li> </ul> <h2 id="2026-10-25-a-csv-imported-without-storage-191">2026-10-25 — a CSV imported without storage (#191)<a class="headerlink" href="#2026-10-25-a-csv-imported-without-storage-191" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>6cc6349..6ff7c7d</code></li> <li><strong>Pages:</strong></li> <li><code>adr/0019-the-csv-import-is-parsed-in-the-browser.md</code> — <strong>created</strong>: the file never leaves the browser, the <code>csv</code> origin has no adapter and no storage, the map and the bounds are published by the API, and the answer is a per-row report. The alternatives (bucket upload, server-side text parse, one transfer per row, a durable import ledger, all staging fields now) and their reasons are recorded there.</li> <li><code>adr/index.md</code> — <strong>updated</strong>: the 0019 row.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the Importação CSV card in the ingestion screen, the table row that now says a curator may type a record <strong>or</strong> import a CSV, and the per-row answer (accepted/duplicated/invalid, partial import, <code>PENDING_AI</code>).</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: a "The CSV import" section beside the manual entry (the browser parse, the spec route, the 413, the duplicate rule, the one transfer and the re-import caveat), the two routes in the API table, the origins paragraph (<code>csv</code> has no adapter configuration) and the permissions sentence that now names both curator writes.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: <code>raw_data.created_by</code> is set by the form-based origins (<code>manual</code>, <code>csv</code>), and <code>ingestion_origins.config</code> says the csv origin has none. The Portuguese <code>raw_data</code> row had lost <code>origin_code</code> and the two author columns; the round put them back in step with the English table.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: the hook flags <code>package.json</code> and <code>sources.py</code>, but the change adds a contributor test script and an origin vocabulary, not a setting or an installation step; the <code>curator:test</code> command is in <code>CONTRIBUTING.md</code>, where the local checks live.</li> <li><code>.dsh/skills/ingestion/SKILL.md</code> — <strong>updated</strong>: the CSV rules (no storage, the one catalogue and the limits, the strict row rules, the <code>description_ids</code> staging window).</li> <li><code>CHANGELOG.md</code> — <strong>updated</strong>: the Unreleased/Added entry.</li> <li><code>mkdocs.yml</code> — <strong>updated</strong>: the ADR 0019 nav entry.</li> <li><strong>Why the round exists.</strong> #191 was the CSV ingestion ADR 0016 deferred, and the issue opened with the constraint the design had to respect: the storage is optional, so an ingestion feature cannot depend on it. The choice recorded in ADR 0019 keeps the file in the browser, publishes one map and one set of bounds, and makes the API the authority over every row.</li> <li><strong>What the round measured.</strong> Full suite <strong>2228 passed, 1 skipped</strong> (the test database on 5434, <code>postgresql+psycopg2</code>; 5433 was held by another process); <code>ruff check</code> and <code>ruff format --check</code> green; <code>basedpyright</code> <strong>0 errors</strong>; the contract was regenerated with the repository's <code>openapi.json</code> + <code>codegen</code> path and the SPA's <code>tsc</code>, ESLint, <code>bun test</code> (<strong>20 pass</strong>) and Vite build are green; <code>mkdocs build --strict</code> green and the documentation coverage gate <strong>29 passed</strong>; <code>alembic upgrade head</code> applied <code>191c5f0a7b2e</code> and <code>alembic check</code> reports no new operations. Rendered with the headless shell over CDP at 1280×1600, against the built SPA served by the API and a temporary session: <code>.render/191-preview.png</code> is the card with the detected headers, the preview table and the local duplicate warning; <code>.render/191-result.png</code> is the per-row report — 2 accepted, 1 duplicated and 1 invalid (<code>A data 'banana' não foi reconhecida.</code>) — with the transfer enqueued. The two accepted rows and the queued transfer were scratch data and were deleted after the round; the temporary session and the sample file are gone, and the screenshots remain.</li> <li><strong>Not verified, and said so.</strong> The transfer was <strong>not run</strong> during the render — no runner was attached — so the archive description reaching <code>PENDING_AI</code> was not observed in the browser; it is asserted by the integration test that runs <code>worker_archive_transfer.execute</code> over the imported rows. The <strong>Baixar modelo</strong> button was not clicked; <code>buildTemplate</code>/<code>toCsv</code> are covered by <code>bun test</code>, and the button only creates the Blob. The HTTP 413 path is a controller test with the service mocked, not a real oversized request. The install guide's <code>no-change</code> is a judgement from the diff, not from a page render.</li> </ul> <h2 id="2026-10-26-the-archive-keeps-the-isadg-record-199">2026-10-26 — the archive keeps the ISAD(G) record (#199)<a class="headerlink" href="#2026-10-26-the-archive-keeps-the-isadg-record-199" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>6ff7c7d..08b3ccb</code></li> <li><strong>Pages:</strong></li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: <code>staging_documents</code> gains <code>date_expression</code> and the archive gains the full ISAD(G) text set plus <code>parent_reference_code</code>, <code>date_expression</code> and <code>original_url</code>; the paragraph explains why the spelling travels beside the parsed date and why the declared parent is a column now.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the description tab grouped in the norm's seven areas, the whole record editable, the two evidence fields shown and not edited, the language select that keeps a value the catalogue does not know, the pending parent, and the digital object card with the disabled control pointing at #198.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>no-change</strong>: the round changed the archive and the dossier, not the ingestion screen; the manual form still ships nine fields, and the sentence that says the rest is #192 is still true.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: no setting moved; the language catalogue is code, not a new environment variable.</li> <li><code>adr/</code> — <strong>no-change</strong>: no decision here is architectural enough for an ADR. The columns are additive, and the language catalogue becoming a table is the facet issue's decision; the module docstring and the issue record the reasoning.</li> <li><strong>Why the round exists.</strong> #192 expands the manual form, and the archive could not store what the form would ask: the transfer copied 14 of the 33 content fields the staging transform parses. The round closes the store first, so the form grows against a table that exists, and so the dossier can finally show the record it claims to own.</li> <li><strong>What the round measured.</strong> Full suite <strong>2238 passed, 1 skipped</strong> (the test database on 5434, <code>postgresql+psycopg2</code>; 5433 is held by another process); <code>ruff check</code> and <code>ruff format --check</code> green; <code>basedpyright</code> <strong>0 errors</strong>; the contract regenerated with the repository's <code>openapi.json</code> + <code>codegen</code> path, and the SPA's <code>tsc</code>, ESLint and Vite build green; <code>mkdocs build --strict</code> green and the documentation coverage gate <strong>29 passed</strong>; <code>alembic upgrade head</code> applied <code>e3084fbf75ba</code> and <code>alembic check</code> reports no new operations. The backfill was measured on the dev database (<code>scrinalia_zeroed</code>): <strong>101</strong> archive rows against <strong>100</strong> staging rows, so the migration filled <strong>100</strong> descriptions and left <strong>1</strong> empty (no staging row), and <code>date_expression</code> is <strong>0</strong> by design, because the staging column did not exist when those rows were parsed. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and the API: <code>.render/199-dossier-top.png</code> is 3.1 (level, typology, reference code, date, dimension and support, the parent named from the branch) and 3.2 with the source link; <code>.render/199-dossier-mid.png</code> is 3.3 and 3.4, where the language select shows <strong>"Português Brasil (fora do catálogo)"</strong> — the scraped spelling, kept instead of erased; <code>.render/199-dossier-bottom.png</code> is 3.5 to 3.7, the diffusion flag, the disabled <strong>Objeto digital</strong> control with the one sentence, and the save bar. The temporary render account and its session were deleted after the round, and the browser profile and driver are gone.</li> <li><strong>Not verified, and said so.</strong> The transfer was <strong>not run</strong> over the collection, so the CDC re-read and the AI re-queue that follow the hash change were not observed: the round-trip is the integration test that stages a record with every field and asserts every column. No field was <strong>saved</strong> through the dossier in the browser; the write path is the same <code>PATCH</code> covered by the curation test, and what the render proves is that the controls exist, are bound to the stored values and render in the right area. The language select was seen with an unknown value only; the known-value options come from the same <code>GET /api/v1/languages</code> list the route test pins.</li> </ul> <h2 id="2026-10-26-the-manual-entry-covers-the-whole-record-192">2026-10-26 — the manual entry covers the whole record (#192)<a class="headerlink" href="#2026-10-26-the-manual-entry-covers-the-whole-record-192" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>08b3ccb..33217f7</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the manual entry has a screen of its own at <code>/sistema/ingestao/nova</code>, typed in the norm's areas; the three behaviours worth knowing (the date is text with the language grammar, the level and the language are selects, the digital object control is disabled until #198) replace the paragraph that said the form shipped the essential fields.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the screens table gains the new row, and the ingestion section says the card opens the form instead of embedding it.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>no-change</strong>: the round writes the same columns the archive gained in #199; nothing new in the schema.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: no setting moved, and the language catalogue is code, not a variable.</li> <li><code>adr/</code> — <strong>no-change</strong>: this is the form catching up with a contract that already existed.</li> <li><strong>Why the round exists.</strong> #199 made the archive store the whole ISAD(G) record; the form was the last surface that still spoke a subset, and the CSV import had to grow with it or the file and the form would accept different vocabularies (ADR 0019 pins the equality).</li> <li><strong>What the round measured.</strong> Full suite <strong>2244 passed, 1 skipped</strong> against the test database on 5434; <code>ruff check</code> and <code>ruff format --check</code> green; <code>basedpyright</code> <strong>0 errors</strong>; the contract regenerated (31 fields in <code>ManualIngestionRequest</code>, <code>attachment_link</code> gone, the result carrying the parsed date); the SPA's <code>tsc</code>, ESLint and Vite build green; <code>mkdocs build --strict</code> green and the documentation coverage gate <strong>29 passed</strong>, including the new screen. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and the API: <code>.render/192-form-top.png</code> is the identity area open — title, level, parent, reference code, the <strong>text</strong> date with its placeholder and help, dimension and support — with the six other areas collapsed, the disabled <strong>Objeto digital</strong> control and the submit disabled while the title is empty; <code>.render/192-form-area.png</code> is 3.4 expanded with the language select and the help under each control; <code>.render/192-ingestion.png</code> is the ingestion screen with the <strong>Entrada manual</strong> card reduced to the explanation and the <strong>Nova descrição</strong> button, the CSV card and the origin cards below. The temporary render account and its sessions were deleted, and the browser profile and driver are gone.</li> <li><strong>Not verified, and said so.</strong> Nothing was <strong>submitted</strong> through the browser: the write path is the integration test that stages a record with every field and asserts every column, and what the render proves is that the controls exist, are bound and sit in the right area. The <code>requires_parent</code> warning was not triggered (no rung that requires a parent was selected in the render); it is a conditional in the same screen. The CSV card was rendered but not re-imported with the wider template: the map and the caps are the same catalogue the spec publishes, covered by <code>test_csv_import.py</code> and by the equality pin.</li> </ul> <h2 id="2026-10-26-one-spelling-per-language-203">2026-10-26 — one spelling per language (#203)<a class="headerlink" href="#2026-10-26-one-spelling-per-language-203" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>e9f0062..8660c6f</code></li> <li><strong>Pages:</strong></li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>updated</strong>: <code>language_name</code> holds the catalogue's spelling, the fold happens at the transfer with the variants declared as aliases, and a language the catalogue does not know is kept as the source wrote it; <code>staging_documents</code> keeps the declared text.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: the dossier's select reads the catalogued name (a variant is folded before it arrives), and the <code>(fora do catálogo)</code> marker is now only for a language the catalogue has never heard of.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>no-change</strong>: the manual form already sent a catalogue name; the fold is about values arriving from the origins and from the past.</li> <li><code>adr/</code> — <strong>no-change</strong>: it is the catalogue #199 created, given the normalisation every closed catalogue in this codebase has.</li> <li><strong>Why the round exists.</strong> The render that closed #199 measured the collection: <code>Português Brasil</code> in 78 descriptions and <code>Português/Brasil</code> in 22, both shown as unknown because the dossier compares strings and the catalogue says <code>Português</code>. Keeping the source's text is the right default; two spellings of one language in a closed list is not.</li> <li><strong>What the round measured.</strong> Full suite <strong>2270 passed, 1 skipped</strong> against the test database on 5434; <code>ruff check</code> and <code>ruff format --check</code> green; <code>basedpyright</code> <strong>0 errors</strong>; <code>mkdocs build --strict</code> green and the documentation coverage gate <strong>29 passed</strong>; <code>alembic upgrade head</code> applied <code>6c46426b048a</code> and <code>alembic check</code> reports no new operations. On the dev database the migration folded <strong>100</strong> rows in <code>staging_documents</code> and <strong>100</strong> in <code>archive_documents</code> (the collection has 101 descriptions, one of them with no language), and the two spellings became one: <code>Português 100</code>, <code>NULL 1</code>. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and the API: <code>.render/203-language.png</code> is the dossier's 3.4 area with the <strong>Idioma</strong> select reading <code>Português</code> — the same record that showed <code>Português Brasil (fora do catálogo)</code> before the fold. The temporary render account and its sessions were deleted, and the browser profile and driver are gone.</li> <li><strong>Not verified, and said so.</strong> The fold was not exercised through a <strong>new scrape</strong>: the transfer path is covered by the integration test that stages <code>Português/Brasil</code> and asserts the archive's <code>Português</code> while staging keeps the declared text. The downgrade is a documented no-op and was not run, because it cannot restore the spelling — the fold is lossy by design.</li> </ul> <h2 id="2026-10-26-the-entry-screen-moves-to-the-acervo-205">2026-10-26 — the entry screen moves to the Acervo (#205)<a class="headerlink" href="#2026-10-26-the-entry-screen-moves-to-the-acervo-205" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>8660c6f..35d11cb</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: <em>Nova descrição</em> is <code>/acervo/nova</code>, the first entry of the <em>Acervo</em> menu, with a section of its own: the <strong>Digitar</strong> and <strong>Importar CSV</strong> tabs (the mode travels in <code>?modo=csv</code>), the destination said once, and the redirect from <code>/sistema/ingestao/nova</code>. The <em>Ingestão</em> section loses the two cards and says the curator reads the origins read-only.</li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the manual entry is the <strong>Digitar</strong> tab and the CSV is the <strong>Importar CSV</strong> tab, both at <code>/acervo/nova</code>; <code>/sistema/ingestao</code> is origins only, and its <code>CURATE</code> card now buys the status read, not a curator write.</li> <li><code>guides/install.md</code> / <code>install.pt.md</code> — <strong>updated</strong>: after the migration <code>/sistema/ingestao</code> configures the origins and <code>/acervo/nova</code> is where a description is typed or imported.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>no-change</strong>: no table or column moved; the staging and transfer paths are the same.</li> <li><code>adr/</code> — <strong>no-change</strong>: the API and the pipeline are untouched. This is where a screen lives, not an architecture decision.</li> <li><strong>Freshness.</strong> Before the round the hook named <code>guides/curate.md</code> (the conflict tabs, #220) and <code>guides/install.md</code> (the 1.6.0 version bump). Both were read; the conflict screen was already described as tabs, the version is not a fact the install guide stores, and the updates above are what closes the range.</li> <li><strong>Why the round exists.</strong> #205 moved the two input paths of the collection out of the ingestion configuration page: "Nova descrição" was the last step of Configurações -> Operação -> Ingestão, and the CSV import lived as a second card on a page about origins. The screen creates the object the Acervo lists, so it now lives there, with the two input shapes as tabs.</li> <li><strong>What the round measured.</strong> Full suite <strong>2270 passed, 1 skipped</strong> against the test database on 5434 (<code>postgresql+psycopg2</code>; 5433 is held by another process and its <code>test_user</code> refused the password); <code>ruff check</code> and <code>ruff format</code> green and <code>basedpyright</code> <strong>0 errors</strong> through the commit hook; <code>bun run --cwd apps/curator typecheck</code>, <code>lint</code> and <code>build</code> green; <code>bun test</code> <strong>20 pass</strong>; <code>testing/unit/curator</code> and <code>testing/unit/docs</code> <strong>32 passed</strong>; <code>mkdocs build --strict</code> green; no API change and no contract regeneration. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and the API: <code>.render/205-digitar.png</code> is <code>/acervo/nova</code> with the menu entry and the form; <code>.render/205-csv.png</code> is <code>?modo=csv</code>; <code>.render/205-csv-preview.png</code> is a sample CSV mapped header by header with one invalid row before any submit; <code>.render/205-ingestao.png</code> is the origins page with no entry cards. The CDP probe switched tabs to <code>/acervo/nova?modo=csv</code>, kept a typed draft across the round trip, and landed <code>/sistema/ingestao/nova</code> on <code>/acervo/nova</code>. The temporary session row was deleted and the driver and profile removed.</li> <li><strong>Not verified, and said so.</strong> Nothing was <strong>submitted</strong> through either tab: the manual and CSV writes are the integration tests, and the render proves the screen, the tabs, the mapping preview and the redirect. The permission hiding was not exercised with a curator or viewer account — the render used the administrator — so the mirror is <code>permissions.ts</code> and the API guard; the final permission of the origins page, and whether it needs a read-only mode, is #206's decision.</li> </ul> <h2 id="2026-10-26-the-origins-page-becomes-configuration-206">2026-10-26 — the origins page becomes configuration (#206)<a class="headerlink" href="#2026-10-26-the-origins-page-becomes-configuration-206" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>5da49c6..479e459</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> / <code>operate.pt.md</code> — <strong>updated</strong>: the screen is <em>Origens</em> and has two sections: the configured sources (add picker, configuration, agenda, <strong>Desativar/Ativar</strong>, <strong>Enfileirar execução</strong>, the effective-configuration summary) and the <strong>Entradas</strong> (manual/CSV read-only, with the link to <code>/acervo/nova</code>). The page is <code>OPERATE</code>, the picker is the existing <code>PATCH</code>, pausing keeps the row and the agenda, a fresh install starts empty, and the health probe counts only the configurable origins.</li> <li><code>guides/curate.md</code> / <code>curate.pt.md</code> — <strong>updated</strong>: <em>Ingestão</em> becomes <em>Origens</em> in the <em>Sistema</em> table and section; the page stops being the "operator only" exception (the card is <code>OPERATE</code>, the read routes stay authenticated), and the entry paths remain under <em>Nova descrição</em> in the Acervo.</li> <li><code>guides/install.md</code> / <code>install.pt.md</code> — <strong>updated</strong>: a fresh installation has no configured source; the operator adds the <code>pmc</code> definition from the picker, and the <code>PUBLIC_SCRAPE_*</code> values are that adapter's fallback when the fields are empty.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>no-change</strong>: the migration is data (a row is deleted when it is still the seed); no column or index moved.</li> <li><code>adr/</code> — <strong>no-change</strong>: the origin is still a definition plus a row (ADR 0016); the page now models the distinction instead of changing it.</li> <li><strong>Freshness.</strong> The previous floor ended at the #205 feature commit, whose content the squash <code>5da49c6</code> carries; this round read the merged pages again and the #206 updates are what closes the range from <code>5da49c6</code>.</li> <li><strong>Why the round exists.</strong> #206: after #205 removed the entry cards, the origins page still rendered every definition as a card, so "never configured" and "paused" looked the same, <code>manual</code>/<code>csv</code> appeared as sources, and the empty state was unreachable. The page now reads the rows, offers the definitions without one, and has a lifecycle.</li> <li><strong>What the round measured.</strong> Full suite <strong>2275 passed, 1 skipped</strong> against the test database on 5434 (<code>postgresql+psycopg2</code>); <code>ruff check</code>, <code>ruff format</code> and <code>basedpyright</code> green through the commit hook; the SPA's <code>tsc</code>, ESLint and Vite build green; <code>bun test</code> <strong>20 pass</strong>; <code>testing/unit/curator</code> and <code>testing/unit/docs</code> <strong>53 passed</strong>; <code>mkdocs build --strict</code> green; the contract regenerated (the additive <code>configurable</code> and <code>available</code>); <code>alembic upgrade head</code> applied <code>f1090f825a94</code> against the dev database (the <code>pmc</code> row kept: it has provenance) and against a fresh scratch database (the <code>pmc</code> row deleted; <code>manual</code> and <code>csv</code> kept), and <code>alembic check</code> reports no new operations. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and an API on the scratch database: <code>.render/206-empty.png</code> is the empty <em>Origens</em> section with <strong>Adicionar origem</strong> and the two read-only <strong>Entradas</strong>; <code>.render/206-picker.png</code> is the picker offering the PMC definition; <code>.render/206-added.png</code> is the card active with the effective configuration (<code>Espera entre requisições (s): 0.5</code>) and the add action gone; <code>.render/206-paused.png</code> is the same card paused, with <strong>Ativar</strong> and "0 ativas"; <code>.render/206-health.png</code> is the health panel showing <strong>0 de 1 origens ativas</strong>, only PMC, and <strong>Abrir origens</strong>. The CDP probe clicked the add, the activation and the pause; the scratch database and its session were dropped after the round, and the driver and profile removed.</li> <li><strong>Not verified, and said so.</strong> The permission hiding was not exercised with a curator or viewer account — the render used the administrator — so the read-only direct URL is the <code>permissions.ts</code> mirror plus the API guard. The manual/CSV entries are read-only by design, so their pause/re-enable has no control on the page (the <code>PATCH</code> remains) and was not exercised through the UI. The migration's downgrade is a documented no-op and was not run; the verification of the deletion is the fresh scratch database above, and the preservation is the dev database. Only one definition (<code>pmc</code>) is available today, so the picker was not exercised with several entries; the retired-code row is out of scope and is ignored by both the page and the probe.</li> </ul> <h2 id="2026-10-26-one-operations-screen-for-the-panel-and-the-ledger-207">2026-10-26 — one operations screen for the panel and the ledger (#207)<a class="headerlink" href="#2026-10-26-one-operations-screen-for-the-panel-and-the-ledger-207" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>f6efb38..1c96177</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: <em>Workers de IA</em> became <em>Workers e execuções</em>, one screen with two tabs in the URL (<em>Painel</em> and <em>Execuções</em>). The screen table merges the two rows, the section says the last run links to the filtered ledger and that the old path redirects, and the Configurações tab table drops the Execuções card.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the translation of the same three places.</li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the SPA table merges <code>/sistema/workers</code> and <code>/sistema/execucoes</code> into one row (the two tabs, the last-run link, the redirect); the polling paragraph says only the active tab is mounted, and the cancel section names the tab.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same three places.</li> <li><code>adr/0016-the-origin-is-a-first-class-object.md</code> — <strong>updated</strong>: the ingestion is seen in the execution ledger at <code>/sistema/workers</code>, not in a <code>/sistema/execucoes</code> screen; the object, its row and its route are unchanged.</li> <li><code>adr/0018-trials-and-the-human-gabarito.md</code> — <strong>updated</strong>: the trial is distinguishable in the execution ledger (the <em>Execuções</em> tab); the trial semantics are unchanged.</li> <li><code>guides/data-model.md</code> / <code>data-model.pt.md</code> — <strong>no-change</strong>: no table, column or index moved.</li> <li><code>guides/install.md</code> / <code>install.pt.md</code> — <strong>no-change</strong>: no setting or command changed.</li> <li><strong>Why the round exists.</strong> The run-check loop crossed three screens and three URLs: the panel enqueued and showed the last run as plain text, and the ledger was a second door. #207 makes them one route with the tab in the URL, links the last run to the ledger filtered by its worker and with the exact row highlighted, and keeps <code>/sistema/execucoes</code> redirecting there with the search preserved. The configuration stays a screen of its own (#53).</li> <li><strong>What the round measured.</strong> <code>bun run --cwd apps/curator typecheck</code> and <code>lint</code> green; the Vite build green (2117 modules); <code>pytest testing/unit/curator testing/unit/docs</code> <strong>53 passed</strong>; the commit hook ran ruff, ruff-format and basedpyright green; <code>mkdocs build --strict</code> green. Rendered with the headless shell over CDP at 1280×1500 against the Vite dev server and an API on a scratch database (<code>scrinalia_render_207</code>, dropped after): <code>207-panel.png</code> is the heading <em>Workers e execuções</em> with the two tabs, the runners card and the ten workers; <code>207-panel-enqueued.png</code> is the panel after one run, with "Execução #1 enfileirada; ver no ledger" and the same link under <em>Última execução</em>; <code>207-ledger.png</code> is the <em>Execuções</em> tab with the worker filter set and the run row ringed; <code>207-settings-operacao.png</code> is the landing's <em>Operação</em> tab with four cards and no <em>Execuções</em> card. The link's href was <code>/sistema/workers?aba=execucoes&worker=transfer&run=1</code>, and clicking it landed on that URL with exactly <strong>1</strong> highlighted row. <code>/sistema/execucoes?worker=transfer&run=1</code> redirected to <code>/sistema/workers?worker=transfer&run=1&aba=execucoes</code>. Clicking the tab bar itself moved <code>?aba=execucoes&worker=transfer&run=1</code> to <code>?aba=painel&worker=transfer</code> and back to <code>?aba=execucoes&worker=transfer</code>: the filter survives, and <code>run</code> is dropped by a change that is not itself.</li> <li><strong>Not verified, and said so.</strong> The permission mirror was not exercised with a curator or viewer account — the render used the administrator, so the read-only direct URL is the <code>permissions.ts</code> mirror plus the API guard, unchanged by this round. The redirect was exercised with <code>worker</code> and <code>run</code> only, not with <code>status</code>, <code>origin</code>, <code>fingerprint</code> or <code>offset</code>; it carries the whole search object and the validator is the same one the ledger reads. The polling cadence was not measured over time: the evidence that only one tab polls is structural (only the active tab is mounted), not a stopwatch. The ledger was rendered with one queued run, so the rich row (trial, retry, cancellation note) is the existing screen's behaviour, not this round's.</li> </ul> <h2 id="2026-10-27-one-hint-element-for-the-explanations-that-do-not-deserve-a-paragraph-208">2026-10-27 — one hint element for the explanations that do not deserve a paragraph (#208)<a class="headerlink" href="#2026-10-27-one-hint-element-for-the-explanations-that-do-not-deserve-a-paragraph-208" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>1c96177..7843d9e</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>no-change</strong>: the routes, writes and flows the guide documents did not move. The new <code>Hint</code> only changes how a secondary explanation is revealed on three screens — the conflict threshold, the CSV column badge and the worker run/effective configuration — and the guide never described those help affordances, only the actions they sit beside.</li> <li><code>guides/curate.pt.md</code> — <strong>no-change</strong>: the same translation, for the same reason.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: <code>WorkersPanel.tsx</code> moved only to place the existing explanations behind the shared hint; the other source in the range is the Dependabot bump of <code>astral-sh/setup-uv</code> in <code>.github/workflows/ci.yml</code>, which changes no command, probe or worker behaviour the operator guide describes.</li> <li><code>guides/operate.pt.md</code> — <strong>no-change</strong>: the same two sources, the same reason.</li> <li><strong>Why the round exists.</strong> The same second sentence was rendered four ways and chosen per screen, with the native <code>title</code> as the "discreet" default: hover-only, after a delay, never on focus, absent on touch, and unreliable for assistive technology. #208 adds one <code>Hint</code> primitive — a real button with <code>aria-describedby</code> and a <code>role="tooltip"</code> panel whose text stays in the DOM while closed — and migrates the first three places. <code>Field.info</code> sits outside the <code><label></code>, because a button inside a label is invalid HTML and pollutes the input's accessible name. <code>Tabs</code> uses the primitive through <code>asChild</code>, so the tab itself is the trigger and the interim <code>title</code> plus <code>sr-only</code> text from #220 is gone. The rule lives in the <code>curator-ui</code> skill.</li> <li><strong>What the round measured.</strong> <code>bun run --cwd apps/curator typecheck</code>, <code>lint</code> and <code>build</code> green (2118 modules); <code>bun test</code> <strong>26 pass</strong> (20 CSV + 6 hint); <code>pytest testing/unit/curator</code> <strong>24 passed</strong>; <code>pre-commit run --all-files</code> green. Rendered at 1440×900 with the headless shell over CDP against the Vite dev server and the dev API, with a disposable session row deleted after the round: <code>208-conflicts-hint.png</code> is the threshold tooltip open with the focus on its button, <code>208-conflicts-escape.png</code> is the same panel closed after Escape while the button keeps focus, <code>208-tab-hint.png</code> is the <em>Pendentes</em> tab description from the tab itself, <code>208-workers-run.png</code> is the run-button explanation, <code>208-workers-config.png</code> is the effective configuration hint (and what caught the inherited <code>uppercase</code> before it shipped), and <code>208-csv-badge.png</code> is the column badge after a real file was set on the CSV input. The page's tooltip state was <code>open</code> on focus and <code>closed</code> after Escape; the text was read back from the described panel, not assumed from the screenshot.</li> <li><strong>Not verified, and said so.</strong> The pointer hover was driven through the DOM test and not with a physical mouse over the rendered screen; touch was exercised as a click in <code>happy-dom</code>, not on a touch device. The other <code>title</code> attributes remain (180 in <code>src</code>, three fewer than before this round), because the issue retires the pattern as uses migrate, not in one sweep. The tab hints remain inside the <code>tablist</code> in the accessibility tree as non-focusable tooltip nodes; the tabs themselves are still the owned elements and the described text is read on focus. No colour token, route, schema or contract changed.</li> </ul> <h2 id="2026-10-27-the-copy-sweep-reaches-the-rest-of-the-interface">2026-10-27 — the copy sweep reaches the rest of the interface<a class="headerlink" href="#2026-10-27-the-copy-sweep-reaches-the-rest-of-the-interface" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>b75d74e..7e9a5b3</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the sweep renamed two screens the guide names by label — <code>Excluídas</code> → <code>Histórico de exclusões</code>, <code>Nova descrição</code> → <code>Nova Descrição</code> — and the two purge panels (<code>Ledger das purgas</code> → <code>Registro das purgas</code>, <code>Ledger dos expurgos</code> → <code>Histórico de expurgos</code>). A reader sent to <code>Acervo › Excluídas</code> would not find the entry. No route, write or flow changed.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same four names, in the translation.</li> <li><code>guides/install.md</code> — <strong>updated</strong>: the backup section quotes the deletion screen by name and calls it a trail; it is <code>Histórico de exclusões</code>, and it is a record. What the section explains did not move: the snapshot row, no restore, a node with children refused.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same paragraph, same reason.</li> <li><code>guides/operate.md</code> — <strong>no-change</strong>: its only moved source is <code>SystemHealthRoute.tsx</code>, whose copy said <code>dimensions</code> and <code>NER/parser</code> for the effective configuration the page already describes. The page documents runs, queues, retries and the worker catalogue, and it names <code>ledger</code> as the append-only table, not as a panel — the sweep renamed the screens, not the mechanism.</li> <li><code>guides/operate.pt.md</code> — <strong>no-change</strong>: the same source, the same reason.</li> <li><strong>Why the round exists.</strong> The first copy pass covered nine files; the second swept the remaining fifty-three (commit <code>7e9a5b3</code>). It replaced the system's vocabulary with the task — <code>ledger</code> → histórico, <code>staging</code>/AI queue out, rule kinds out of caps, <code>Engine</code> → <code>Motor</code> — and corrected three sentences that were false rather than technical: the anomaly empty state, the "sem categoria" badge, and the cleaning-rule preview's sample count. <code>worker</code> stays where it names the operations object, which is the exception the <code>curator-ui</code> skill now records (<code>a0655d4</code>).</li> <li><strong>What the round measured.</strong> <code>bun run --cwd apps/curator typecheck</code>, <code>lint</code> and <code>test</code> green (26 pass); <code>pytest testing/unit/curator testing/unit/docs</code> <strong>53 passed</strong> (the copy gate included); <code>basedpyright</code> green on both commits through the pre-commit hook; <code>freshness.py</code> reports these three pages and no other.</li> <li><strong>Not verified, and said so.</strong> No screen was rendered for this round: the evidence is the four gates above and the diff, not pixels. The pages quote labels, not behaviour, and no route, schema or contract changed.</li> </ul> <h2 id="2026-10-27-the-second-copy-read-and-the-screen-it-renamed">2026-10-27 — the second copy read, and the screen it renamed<a class="headerlink" href="#2026-10-27-the-second-copy-read-and-the-screen-it-renamed" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>7e9a5b3..0876ead</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the copy pass renamed <code>/entidades/excecoes</code> from "Exclusões de NER" to "Remover de Entidades", and the guide quotes that label in the screen table and in the section heading. No route, write or flow changed.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same two names, in the translation.</li> <li><code>guides/install.md</code> — <strong>no-change</strong>: its only moved source in the range is <code>lib/screens.ts</code>, and the install guide names neither the Tags screen nor this one; what moved there is the Tags hint/description and two screen descriptions.</li> <li><code>guides/install.pt.md</code> — <strong>no-change</strong>: the same source, the same reason.</li> <li><strong>Why the round exists.</strong> A second, closer copy read over the subject, entity and dossier screens (<code>8b3acfc</code>) plus the correction it made necessary (<code>0876ead</code>): the entities list still linked to the old label, so the list and its destination named the same screen two ways. The pass also reopened the retired-word gate, which caught <code>apagar</code> and <code>unificação</code> and required the canon's <code>Excluir</code> and <code>Mesclagem</code> again — and a stray double quote in one JSX text node, which is also what hid that node from the scan.</li> <li><strong>What the round measured.</strong> <code>tsc</code>, ESLint, 26 bun tests and 24 pytest for the copy commit; <code>ruff check</code>, <code>ruff format --check</code>, <code>basedpyright</code>, <code>bun run contract</code> (no drift), <code>alembic check</code> (no drift) and <code>mkdocs build --strict</code> for this round. <code>freshness.py</code> reported these two pages and no other.</li> <li><strong>Not verified, and said so.</strong> No screen was rendered: the evidence is the gates and the diff.</li> </ul> <h2 id="2026-10-28-the-plan-becomes-the-tree-it-already-was">2026-10-28 — the plan becomes the tree it already was<a class="headerlink" href="#2026-10-28-the-plan-becomes-the-tree-it-already-was" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>ba1fb02..8f891d8</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the plan section now describes the rungs as the tree <code>parent_code</code> implies, the leading box that closes a whole branch and states the hidden rungs and decisions, the filter that keeps a match's ancestors and branch, and the URL that records the closed roots. No route, write or undo changed.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph in the translation.</li> <li><strong>Why the round exists.</strong> #233 replaced one flat list indented by <code>plan.depth</code> with nested lists and two pure functions (<code>buildPlanTree</code>, <code>planRows</code>); a collapsed branch now hides its subtree without unmounting it, so an open decision body and anything typed in its form survive. The status filter moved to the client because a tab fetched alone has no ancestors to draw.</li> <li><strong>What the round measured.</strong> <code>tsc</code>, ESLint, the Vite build, 47 bun tests (18 new) and 25 pytest (the copy gate) on the code commit; the full suite ran against the test database on port 5434 (2292 passed, 1 skipped). The screen was rendered against the real 41-rung catalogue with the headless shell, and 29 CDP checks covered collapse/expand, the hidden counts, the status/flag/code filters, forced ancestors, the URL, and the two global controls. <code>Recolher os decididos</code> was not clicked against a real fully decided branch (the dev catalogue has none); its set is pinned by three pure tests. The depth-cap title above six levels was not observed either: the deepest rendered branch is five levels below the root.</li> <li><strong>Not verified, and said so.</strong> The API and the SPA ran against the development database; the admin password was reset for the render and restored afterwards, and the full suite used the test database, not the development one.</li> </ul> <h2 id="2026-10-28-the-access-points-are-chips-and-the-string-is-read-before-it-is-written">2026-10-28 — the access points are chips, and the string is read before it is written<a class="headerlink" href="#2026-10-28-the-access-points-are-chips-and-the-string-is-read-before-it-is-written" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>8f891d8..c15060d</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the Nova Descrição section now says the access points are chips over the catalogue, that the type-ahead picks an existing spelling and <em>Adicionar</em> keeps the archivist's, and that the summary under the control names what the transfer will discard, create or map to a synonym. It names <code>POST /api/v1/taxonomy/tags/preview</code> as the read behind the summary and says the preview removes nothing.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph in the translation.</li> <li><code>guides/data-model.md</code> — <strong>no-change</strong>: the repository gained two read-only lookups (<code>get_tag_identities_by_names</code>/<code>by_ids</code>) over existing columns and the unique normalized-name index; no table, column, constraint or relationship changed, so the model page is still true.</li> <li><code>guides/data-model.pt.md</code> — <strong>no-change</strong>: the English page it translates did not change.</li> <li><strong>Why the round exists.</strong> #229: the creation form asked for one comma-separated string and the transfer then lowercased it, dropped banned, short and long terms and created unknown spellings, with no message. The field is now a chip editor over the catalogue; the explicit <em>Adicionar</em> keeps a new term, and one read-only summary says what the transfer will do with the whole string. The wire is still <code>indexing_points: str | None</code>; the CSV column and the scraper are untouched, and the transfer remains the only writer.</li> <li><strong>What the round measured.</strong> <code>ruff check</code>, <code>ruff format --check</code>, <code>basedpyright</code>, 2310 pytest passed and 1 skipped against the test database on port 5434, 61 bun tests, <code>tsc</code>, ESLint, <code>bun run contract</code> (the new route), <code>alembic check</code>, the docs coverage suite and <code>mkdocs build --strict</code>. The screen was rendered against the development database with the headless shell: the old free-text field, then the new chips with an existing tag, a synonym, a banned term and a new term, plus the type-ahead open. The first render showed the input's own focus outline around the text field only; the ring was moved to the chips box and the screen re-rendered. The warning line of the summary was moved first after the first render.</li> <li><strong>Not verified, and said so.</strong> The render used the development database; the admin password was reset for it and its hash restored afterwards, and the render sessions were deleted. The summary's copy was observed on the development catalogue; the blocked, synonym and new states are pinned by the API and component tests, not by a catalogue built for the screenshot. The CSV import was not re-rendered, because its column, map and copy are unchanged.</li> </ul> <h2 id="2026-10-28-the-producer-field-copies-a-name-it-does-not-link">2026-10-28 — the producer field copies a name, it does not link<a class="headerlink" href="#2026-10-28-the-producer-field-copies-a-name-it-does-not-link" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>ee24d79..6ba1d64</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the Nova Descrição section now describes <code>Produtor(es)</code> as a name autocomplete over the entity catalogue: the fragment between separators that a pick replaces, the verbatim copy (the catalogue stores names lowercased), the deliberate absence of an entity-type filter, and the sentence that a copied name is not a link.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same paragraph in the translation.</li> <li><strong>Why the round exists.</strong> #230 was scoped down after the measurement that the producer variants in this database are an ingestion shape (7 spellings for 5 creators, all 101 descriptions from the <code>pmc</code> origin) and that the entity axis has no creator role. What shipped is the convenience half: the copy. The relation — description ↔ creator, the entry normalisation, the list filter and a producer worker — moved to #242, in the <em>Future Ideas</em> milestone.</li> <li><strong>What the round measured.</strong> <code>tsc</code>, ESLint, 72 bun tests (11 new in <code>apps/curator/tests/entityNameField.test.tsx</code>) and 25 pytest in <code>testing/unit/curator</code> (the copy and manual-form gates). The screen was rendered against the development database with the headless shell: the creation form with the 3.2 Contexto area open and the dropdown for "prefeitura" (two entities, <code>ORG · 23 descrições</code> and <code>LOC · 1 descrição</code>), the value after the pick with the caret at its end, and the dossier of description 391, where typing ", gui" after the existing "Prefeitura Municipal de Curitiba" searched only the fragment and the pick replaced only it. The field sits inside the collapsed area on the creation screen, which is the form's default and not a defect: the screenshot of the form before opening it shows no producer control, and that is the screen the archivist meets.</li> <li><strong>An observation, not a fix.</strong> "gui" returned "rua assungui" (<code>LOC</code>, 3 descriptions) before "guido ghilardi" (<code>PER</code>, 1): the endpoint orders by usage, which the dossier's entity picker already inherited. Nothing in this change touches that ordering.</li> <li><strong>Not verified, and said so.</strong> The dossier pick was checked in the draft and the browser closed without saving, so the write path is the existing one and was not exercised. The suggestion list was not seen against an empty catalogue. Nothing in the CSV import, the public DTO or the API changed, and none of them was re-rendered.</li> </ul> <h2 id="2026-10-28-the-blocked-spellings-are-a-screen">2026-10-28 — the blocked spellings are a screen<a class="headerlink" href="#2026-10-28-the-blocked-spellings-are-a-screen" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>263d936..67f00ad</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: the Assuntos table gains <code>Grafias bloqueadas — /assuntos/grafias</code> and Tags loses the banned terms; <code>#### Stopwords</code> moves out of the Tags section and becomes its own <code>###</code> section, with the two objects stated (meaning × spelling), the reciprocal link and the <code>?aba=stopwords</code> redirect; the Similaridade, Classificar and <em>Não é assunto</em> paragraphs now name the screen instead of the tab, and the <em>Não é assunto</em> section says which of the two each one is.</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same translation, in the SPA's vocabulary.</li> <li><code>guides/data-model.md</code> — <strong>no-change</strong> beyond the name: two sentences said "the Stopwords screen" for <code>save_stopwords</code>, and the screen is now <code>Grafias bloqueadas</code>; the table, the columns and the ledger paragraphs still hold.</li> <li><code>guides/data-model.pt.md</code> — <strong>updated</strong>: the same two references.</li> <li><strong>Why the round exists.</strong> #232 found two screens sharing a vocabulary, a dataset and a verb, with the only destructive write of the axis two clicks under a page that never mentioned it. The fix moved the blocked spellings to their own screen, renamed the copy at the object (a <em>grafia</em>, not a term), and made the two screens name each other.</li> <li><strong>What the round measured.</strong> <code>tsc</code>, ESLint, the Vite build, 72 bun tests, <code>basedpyright</code> (0 errors) and 56 pytest across <code>testing/unit/curator</code> (27, including the three new copy rules) and <code>testing/unit/docs</code> (29). The screens were rendered against <code>scrinalia_render</code> with the headless shell: <code>/assuntos/grafias</code> with 208 blocked spellings in three axes, a ban whose origin is a verdict (<code>veio de “não é assunto”</code>) carrying the <code>ver em Não é Assunto</code> link, the danger card <code>Excluir as tags com esses nomes</code> disabled until the impact preview, and the ledger below it; the verdict screen with the reciprocal <code>Grafias bloqueadas</code> button and the disclosure sentence naming where the tags are deleted; and <code>/assuntos/excecoes?termo=…</code> opening its ledger filtered (<code>1 de 6 termos vetados</code>) and scrolled to it, which is the landing the new link promises. <code>/assuntos/tags?aba=stopwords</code> was followed and answered <code>/assuntos/grafias</code>.</li> <li><strong>What was not verified.</strong> The write path was exercised only to plant the fixture (<code>POST /subject-exclusions</code> for a synthetic term, which created the <code>EXCLUSION</code> ban the link is drawn from); the impact preview and the tag deletion were not run, and no tag was deleted. The mobile viewport was not rendered. The render database is scratch (<code>scrinalia_render</code>) and was migrated to <code>head</code> for the round; the synthetic term, its ban and the temporary account were left in it and belong to nobody's collection.</li> </ul> <h2 id="2026-10-28-the-interface-in-two-languages">2026-10-28 — the interface in two languages<a class="headerlink" href="#2026-10-28-the-interface-in-two-languages" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>67f00ad..ba20a13</code></li> <li><strong>Pages:</strong></li> <li><code>guides/curate.md</code> — <strong>updated</strong>: a new <em>The interface speaks two languages</em> section states the account selector, the browser fallback before sign-in, the server answering in the request's language, and what does not change (the Portuguese paths and the record's own content).</li> <li><code>guides/curate.pt.md</code> — <strong>updated</strong>: the same translation, in the SPA's vocabulary.</li> <li><code>guides/install.md</code> — <strong>updated</strong>: one sentence after the first administrator: the interface opens in Portuguese and each account can switch to English in the menu.</li> <li><code>guides/install.pt.md</code> — <strong>updated</strong>: the same sentence.</li> <li><code>adr/0020-the-interface-speaks-two-languages.md</code> — <strong>no-change</strong>: the decision already states the account locale, the browser fallback and the server catalogue; the sweep implemented it without changing it.</li> <li><strong>Why the round exists.</strong> #227 is the second half of #45: #45a translated the shell, the anonymous screens and the server's catalogue; this round extracted the remaining strings from the 54 files the ratchet listed — the domain vocabulary and the 28 screens — so no Portuguese literal lives outside <code>src/i18n/</code>.</li> <li><strong>What the round measured.</strong> <code>tsc</code>, ESLint and the Vite build green; 72 bun tests; <code>pytest</code> 2 312 passed, 1 skipped; the copy gate now has <strong>no <code>PENDING</code> list</strong>: every file outside <code>src/i18n/</code> must have no Portuguese, with the two declared data exceptions (the stored <code>SEM TÍTULO</code> sentinel and a URL). The whole sweep was rendered against <code>scrinalia_render</code> (4 842 descriptions) with the headless shell: 56 screenshots, the 28 screens in <code>pt-BR</code> and <code>en</code>. The render found what the accent detector had missed — the English inbox still said <em>Contagens geradas em</em> and formatted the date with a hardcoded <code>pt-BR</code>; the line is now catalogue-backed and goes through <code>formatDateTime</code>, and the re-rendered inbox says <em>Counts generated at 10/28/26, 9:10 PM.</em> The inbox, the Tags screen and the dossier were read by eye in both locales; the other 50 shots were captured and checked for a non-empty frame, not inspected one by one.</li> <li><strong>Not verified.</strong> The dynamic screens were opened with the first record of the collection and its default tab; a write was not exercised in any screen during the render.</li> </ul> <h2 id="2026-10-28-the-origin-panel-action-opens-the-card-it-lives-in-234">2026-10-28 — the origin panel action opens the card it lives in (#234)<a class="headerlink" href="#2026-10-28-the-origin-panel-action-opens-the-card-it-lives-in-234" title="Permanent link">¶</a></h2> <ul> <li><strong>Range:</strong> <code>d746689..e2c5018</code></li> <li><strong>Pages:</strong></li> <li><code>guides/operate.md</code> — <strong>updated</strong>: the <code>/sistema/ingestao</code> paragraph now says what <strong>Configurar</strong> and <strong>Agenda</strong> do on that screen — the click opens the card, shows the panel and brings it into view, and a second click closes the panel while leaving the card open. The page also declares <code>apps/curator/src/routes/OriginsRoute.tsx</code> and <code>components/ingestion/OriginPanel.tsx</code> among its <code>sources:</code>, because it documents this screen and no freshness report could see it before.</li> <li><code>guides/operate.pt.md</code> — <strong>updated</strong>: the same sentence, in the SPA's vocabulary.</li> <li><strong>Why the round exists.</strong> #234 was a state split in two: the action buttons set <code>panel</code>, but the body that reads it exists only while the card is open, and every card starts collapsed. The click was invisible and the form waited, unasked, for the next expansion. The fix moves the rule into <code>useOriginPanel</code>, adds <code>aria-expanded</code>/<code>aria-controls</code> and a pressed state to the two buttons, and scrolls the panel into view.</li> <li><strong>What the round measured.</strong> <code>tsc</code>, ESLint and 79 bun tests green (72 before, 7 in <code>originPanel.test.tsx</code>). The screen <strong>was rendered</strong>: the headless shell over CDP against the dev server and the API on <code>scrinalia_render</code>, with a session row inserted for the round and deleted after. Four shots under <code>.render/</code>: the card collapsed; <strong>Configurar</strong> opening the card and the configuration form in one click, with the button outlined, accent-tinted and pointing down; the second click closing the form with the card still open; and <strong>Agenda</strong> reopening the card on the schedule form. Measured over CDP in those states: the heading stays at <code>y=16</code>, the window and the content column stay at <code>scrollTop=0</code>, and the configuration form's save button lands at <code>y=761</code> — the panel fits the 1000px viewport, so <code>block: "nearest"</code> had nothing to move; the scroll itself is pinned by the unit test, not by these shots.</li> <li><strong>Not verified.</strong> No write was exercised — <em>Salvar configuração</em>, <em>Salvar agenda</em> and <em>Pausar</em> were not clicked, and the operator-permission case (a curator's read-only card) was not rendered. The render used the one configured origin (<code>pmc</code>) in the scratch database; the long-card below-the-fold case is pinned by the unit test's <code>scrollIntoView</code> assertion, not by a screenshot of a card taller than the viewport.</li> </ul> </article> </div> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type="button" class="md-top md-icon" data-md-component="top" hidden> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Back to top </button> </main> <footer class="md-footer"> <nav class="md-footer__inner md-grid" aria-label="Footer" > <a href="../adr/0020-the-interface-speaks-two-languages/" class="md-footer__link md-footer__link--prev" aria-label="Previous: 0020 The interface speaks two languages"> <div class="md-footer__button md-icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg> </div> <div class="md-footer__title"> <span class="md-footer__direction"> Previous </span> <div class="md-ellipsis"> 0020 The interface speaks two languages </div> </div> </a> </nav> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> Made with <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener"> Material for MkDocs </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"annotate": null, "base": "..", "features": ["navigation.sections", "navigation.top", "navigation.footer", "content.code.copy", "search.highlight", "search.suggest"], "search": "../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="../assets/javascripts/bundle.d7400e89.min.js"></script> </body> </html>