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##
2026-10-08 — the documentation base¶
- Range:
2b10f14..4681067 - Pages:
index.md— created (the front door of the site)guides/install.md— createdguides/operate.md— createdguides/curate.md— createdguides/data-model.md— createdadr/0010-documentation-coverage-and-freshness.md— created (the decision this log serves)- Divergences the writing surfaced, and the verdict on each (every one read in the code before the edit; the code fixes are separate commits, not documentation edits):
.env.examplecarried the reference collection'sDB_NAME— fixed (a fresh clone defaulted to somebody else's catalogue name), andguides/install.mdwas updated in the same change, which is the loop this log exists to record.AGENTS.mdlisted the environment variables without the 14AUTH_*and withoutWORKER_RUNTIME_MAX_WORKERS— fixed; the table in the install guide is now the complete one and the coverage gate keeps it that way.- The screen count was wrong in three places (23 menu entries, 24 routes, "25" in the draft of ADR 0010) — fixed to the measured 24, with the menu's 23 explained on the page.
runner.py's comment putmacro-categorylast, against its ownPIPELINE_ORDER; theREADMEclaimed every worker stamps the document, which is false forthumbnailandconflict-judge— both fixed.- Left open as code, not text, and documented as behaviour on the page: the cleaning rules have
no reactivation route although the screen says deactivating is reversible;
POST /taxonomy/tags/suggest-macrowrites nothing and declaresCURATE; and the inbox'sIMPLEMENTEDset does not list/entidades/conflitosor/qualidade/anomalias, which exist.
2026-10-08 — the CPU build of torch¶
- Range:
4681067..b3db7e2 - Pages:
guides/install.md— updated: the lockfile no longer carries the CUDA runtime.torchcomes from the CPU-only index now (measured: 3.46 GB → 0.66 GB, and 2.19 GB of the old lock werenvidia-*packages), so the paragraph that justifiedCUDA_VISIBLE_DEVICES=""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.guides/install.pt.md— updated: the same section, same structure.- One front-matter decision in this change:
pyproject.tomljoined the page'ssources:. 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 to4681067: the page was already stale against the dependency bumps the previous round closed, and adding the source is what made it visible.
2026-10-08 — the suite's own cost¶
- Range:
b3db7e2..e8a7a61 - Pages:
guides/data-model.md— no-change: the only thing the page takes fromtesting/conftest.pyisarchive_error_fingerprint, mirrored into the test schema, and the story of a schema built bycreate_allinstead 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.- A gap worth knowing, not a verdict:
guides/install.mdand its translation also mentiontesting/conftest.py(the sentence about the test schema not being built by Alembic), but they do not declare it insources:, 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.
2026-10-09 — the roadmap leaves the repository¶
- Range:
becba5e..cb85181 - Pages:
guides/operate.md— updated: two sections appended, Measuring the collection (the query, and the one stamp that does not name its producer) and Known limits and accepted trade-offs.guides/operate.pt.md— updated: the translation of both sections, with the Portuguese anchors the build actually emits.index.mdandindex.pt.md— updated: 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.adr/0009-authentication-and-authorization.md— updated: 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 current fact rather than to history.- The pages that did not need work, and that is the finding: the retirement was expected to move a
safety list and a set of failure modes. Both were already documented under the gate —
guides/curate.mdowns "what has no way back" and the arrangement undo,guides/operate.mdowns the reprocessing window and the single-process assumption,models/taxonomy.pyowns the classifier's baseline, andAGENTS.mdowns the failure modes. So the move was the two new sections above, and the rest was deleted as duplication rather than relocated. - Not carried over, on purpose: 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.
- A defect the change exposed, fixed in it: 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.
2026-10-09 — the menu collapses to an icon rail¶
- Range:
cb85181..0b41007 - Pages:
guides/curate.md— updated: a new The menu collapses to icons 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 inlocalStorage(presentation state, not an account setting), the label kept as the link's accessible name with the hint carried by thetitle, the active entry keeping the accent colour, the section headings becoming hairlines — and that the wordmark leaving the menu does not touch the attribution, whichAttributionFooterrenders at the foot of every screen (ADR 0006). Every claim was read inAppShell.tsxand looked at in the SPA in both states, reload included.guides/curate.pt.md— updated: the translation of the clause and of the subsection.- The sentences that did not move: 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.
- Not documented, on purpose: 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.
2026-10-09 — the setup screens move behind a Configurações landing¶
- Range:
0b41007..bec40af - Pages:
guides/curate.md— updated: the introduction (23 menu entries → 16, 24 screens → 25, and what the cards are), The menu hides; it never grants (the card rules: hidden bycan(), 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 Configurações 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.guides/curate.pt.md— updated: the translation of all of the above.guides/operate.md— updated: one sentence, and it is why this page was reported at all — it called the operations panel "theSistemasection of the SPA", and that section no longer exists. It now says the three cards of/configuracoes, under Operação.guides/operate.pt.md— updated: the same sentence, plus the section heading, which carried a "(Sistema)" in parentheses.- A finding the report produced:
guides/operate.mdwent stale for a reason that has nothing to do with the workers it documents — it declaresapps/curator/src/router.tsxamong 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. - Deliberately not documented as done: 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.
2026-10-09 — the first administrator, and the window the lock cannot close¶
- Range:
bec40af..8dde39f - Pages:
adr/0011-first-run-setup-without-an-open-door.md— 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:INSERT … SELECT … WHERE NOT EXISTSis not atomic under READ COMMITTED (two sessions, two rows), whileLOCK TABLE auth_users IN EXCLUSIVE MODEbefore 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 —needs_setupis public, and the hash is ~42 ms measured — and the accepted cost: the lock closes the race, not the window.adr/index.md,adr/0009-authentication-and-authorization.md— updated: 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.guides/install.md— updated: 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 toexec. Two sources were added so the claim is trackable —setup_controller.pyand ADR 0011 — plusSetupForm.tsx.guides/install.pt.md— updated: the same step and the same container paragraph.guides/operate.md— updated: one clause in the authentication-limits bullet — the first account is a window, and{"needs_setup": true}on a reachable instance is an invitation. ADR 0009 and ADR 0011 were added to itssources:(the bullet cites both, and neither was declared).guides/operate.pt.md— updated: the same clause.guides/curate.md— updated: one paragraph at the end of Usuários, 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 itssources:.guides/curate.pt.md— updated: the same paragraph.- The sentences that did not move: the curation guide's screen count and menu count — the setup
form is not a route (
SetupFormrenders in the shell's slot, likeLoginForm), so the router still declares 25 screens and the menu 16 entries. No table, no column and no migration landed: the route readsauth_users, which the data-model page already documents, and the settings are unchanged. - Verified rather than assumed: the two new operations were exercised end to end against a
throwaway database with
curl— 422 on a weak password, 201 withrole: ADMIN,must_change_password: falseand aHttpOnly; SameSite=laxcookie,/auth/meanswering with that cookie, 409 withSetupAlreadyCompleteErroron the second call, andneeds_setupflipping 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 byorigin_guard. - Not verified, and named here so nobody assumes otherwise: the setup screen itself was not looked
at in a browser. Headless Chromium in this environment dumps core before rendering, so the evidence
is
tsc, ESLint, the Vite build and the bundle carrying the screen's text — not pixels. The Tailwind tokens used are the onesLoginFormalready uses.
2026-10-09 — recovery gets a front door¶
- Range:
71781bf..6118170 - Pages:
guides/operate.md— updated: a new Recovering access 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).domains/identity/cli.py,domains/identity/services/auth_service.pyandapi/controllers/users_controller.pyjoined itssources:: the section is a claim about them and they were untracked.guides/operate.pt.md— updated: the translation, same commands, and the two in-page anchors rewritten to the Portuguese the build emits (#recuperar-acesso).guides/curate.md— updated: the page described the accounts screen and never the sign-in screen, which is where recovery now begins. A new Signing in, and forgetting the password 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.components/layout/LoginForm.tsxandPasswordChangeForm.tsxjoined itssources:— theroutes/**glob already coveredUsersRoute.tsx.guides/curate.pt.md— updated: both, in the SPA's vocabulary.- Verified rather than assumed: the two screens were rendered, 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
redefinir senha beside desativar; clicking it opens the card and
document.activeElementis 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; andbg-(--color-warn)/5is in the emitted CSS ascolor-mix(… var(--color-warn) 5% …), not as the invalid bracket form. - A method note for the next round: the full
chromium-<n>/chrome-linux64/chromerenders here onceHOME,TMPDIRand the twoXDG_*variables point inside the workspace; pointed at the real$HOMEit dumps core, which is the failure the previous entry recorded. The screenshots came from that binary, not fromchrome-headless-shell. - What did not move: the screen count (25) and the menu count (16) — no route was added, and
LoginFormrenders 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. - Not carried, on purpose: the CLI's own
list/activatebehaviour 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.
2026-10-09 — the interface gets one name per screen and one verb per action¶
- Range:
6118170..f731d2e - Pages:
guides/curate.md— updated: the page named screens by their old headings, and the headings are now the menu's labels —Diagnósticosplit into Diagnóstico do arranjo and Saúde do sistema, which is the ambiguity issue #23 was filed for. A new One name per screen, one verb per action states the copy canon the page now uses:lib/screens.tsas the one record per screen (route, label, hint) that the menu, the settings card and the<h1>all read, the header's four slots with the status line under the subtitle, and the verb table oflib/copy.ts— including whyAposentar(a catalogue row) andDesativar(an account) are deliberately two words. The prose followed the verbs:Unificar→Mesclar,desativar→aposentarfor theis_activecatalogues.components/layout/PageHeader.tsx,lib/screens.tsandlib/copy.tsjoined itssources:— the section is a claim about all three and none was tracked.guides/curate.pt.md— updated: the translation, same canon, in the SPA's vocabulary.guides/operate.md— no-change: the report named it because itssources:carry the threeSystem*Route.tsxfiles, and this round moved the health screen's label to Saúde do sistema and the run button's word toRodar agora. The page names those screens by route (/sistema/diagnostico,/sistema/workers), 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.- Verified rather than assumed: every one of the 25 routes was rendered, 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
(
Categorias/ As gavetas de assunto / the count, in that order), the two renamed screens (Saúde do sistema,Nível de descrição→Níveis de descriçãoon the settings card and the heading),Aposentarwhere the catalogue row wasdesativar,Rodar agorawhere the same panel hadExecutar agorabeside it,Mesclarwhere the merge panel saidUnificar, and the notices drawing their ring and background after the move toNotice. The dossier renders its badges in the status slot with the record's own title above them. - Measured, not assumed: the tags screen was the only user of
bg-(--color-surface-2), a tokenstyles.cssdoes not define — 1 of the 9 tokens in use — and the box had no background; the two dead form labels (Quem decide,Quem está alterando) 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. - What did not move: 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
(
services/curation_service.py), which is not part of the document. - Not carried, on purpose: the
wordsfield of the ban request is still documented in the contract as "normalizados ao gravar", the one place the old save verb survives. Changing it regeneratesopenapi.jsonandschema.d.tsfor a description no screen renders — a decision of its own, recorded in the commit that left it.
2026-10-09 — the copy pass closes its own gap¶
- Range:
f731d2e..24ba826 - Pages:
guides/curate.md— updated: the page quoted the materialisation panel's button as "Conferir o que será feito", and the panel now says "Conferir impacto" like every other preview in the interface. One line, and it is the report's whole reason for naming the page.guides/curate.pt.md— updated: the same quote, plus the words the page had carried in English all along —rungs(nine times) is now degraus,applyis aplicação, anddry-runis prévia. 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 keepsrungandapply: they are the project's English words, the ones the API docstrings andlib/hierarchy.tsuse.- Why this round exists: the previous entry closed the range with the copy pass declared
complete, and it was not.
components/hierarchy/MaterialisationPanel.tsx— a component and not a screen — keptrung,applyanddry-runthrough all of it, because the per-screen review never opened it and no gate looked. The commit that fixes it also adds the gate (test_curator_copy.py's retired-word rule), which is why the page's quoted string is the only thing the report could see: the panel is not asources:entry of any page. - What did not move: 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.
- Not carried, on purpose: the retired-word list is deliberately short and its two omissions are
documented in the test —
gravarin the sense of recording, which is not theSalvaraction, anddesativar, 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. - The round found a hole in itself, by rendering. 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:
só o apply absorve as tags, 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 (Conferir impacto,de 81 degraus decididos,degraus aprovados,a aplicação segue o vínculo) and the tags proposals tab (incluir no lote de aplicação).
2026-10-09 — the shell stops scrolling the menu away, and the footer reaches the public¶
- Range:
24ba826..05819cd - Pages:
guides/curate.md— updated: a new The screen is one window tall 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, withIníciostanding outside any heading. The menu collapses to icons 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 including the three that come before a session, which was false when it was written.guides/curate.pt.md— updated: the same two sections, in the SPA's vocabulary.- Why the round exists, and what it measured rather than assumed: the rail used to grow to the
height of the page (
min-h-full), so on a long screen the whole menu scrolled out of view — at 1440x900 at the foot of/entidades/lista, a blank rail column and no menu at all — and theoverflow-y-autoon 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 734 in 734: the menu fits, and the active group is the one expanded. - The footer claim was false and is now true.
AttributionFooter'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 fromAppShellbefore 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:/now renders all four elements andv1.0.0. - What did not move: 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 16 entries; the accordion changed how they are shown, not how many there are.
- Not carried, on purpose: the version comes from
pyproject.tomland 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 owninfo.versionis Litestar's default ({"title": "Litestar API", "version": "1.0.0"}) and was not a candidate.
2026-10-09 — one width for every screen¶
- Range:
05819cd..HEAD - Pages:
guides/curate.md— updated: The screen is one window tall gains the width, because the width is part of the shell now: the column has no maximum, and what keeps a cap is the content (a paragraph's measure, a field's width).guides/curate.pt.md— updated: the same paragraph.- Why: the per-screen caps had drifted —
max-w-5xlon most screens,max-w-4xlon five,max-w-6xlon 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 1024; at 1920 both are 1664, on the four screens that used to differ (categories, anomalies, entities and the dossier). - What did not move: the text measure (
max-w-prose,max-w-3xl) and the control widths (max-w-md,max-w-xs, the login forms'max-w-sm) — 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.
2026-10-09 — the identity enters the interface¶
- Range:
05819cd..86796d0 - Pages:
guides/curate.md— updated: a new The identity is on screen 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 why the accent stayed blue: the terracotta isoklch(… 31)and--color-dangerisoklch(… 25), six degrees apart, so a terracotta button and "Excluir" would have been one colour.guides/curate.pt.md— updated: the same section.guides/install.md— updated: 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.guides/install.pt.md— updated: the same sentence.- Why: the interface and the logomark shared no colour. Measured before the change: the identity
is terracotta on navy (
#C4503Fon#14202B=oklch(0.580 0.152 31)onoklch(0.238 0.027 247)) and the theme's accent wasoklch(0.52 0.13 250), a blue. The navy turned out to share the accent's hue (247 against 250), which is what let the rail take the identity's dark without re-tuning the semantic colours. - Verified, rendered: the rail expanded and collapsed and the sign-in card at 1440x900; the tab
computes to
oklch(0.58 0.152 31), the rail tooklch(0.238 0.027 247), the wordmark to"PT Serif"at 45px withdocument.fonts.check('400 45px "PT Serif"')true, the header rule to2px oklch(0.58 0.152 31), and/favicon.svganswers 200 and is indist. - What did not move: 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.
- Not carried, on purpose: the four identity SVGs stay as design sources under
apps/curator/src/assets/brand/whileMark/Lockupinline 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.marca-escura.svgwas reconstructed from the family's geometry and is the one file a person should look at rather than trust.
2026-10-09 — the drawer and the cluster become the words the contract already used¶
- Range:
86796d0..571c6cb - Pages:
guides/curate.md— updated:draweris category (16 times) and the screen isDescobrir Categorias, which is what the rail says.clusterstays: it is the API's own word for a proposed grouping (clusters_found,TRIGRAM/PLURAL/MIXED).guides/curate.pt.md— updated:gavetais categoria (17 times), the same rename.- Why: the interface had its own metaphor for a concept the contract had already named. The API's
field is
category—category_would_be_lost,macro_category_name,CATEGORY— 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: 34 visible strings in the SPA usedgaveta/cluster(Categories 9, Tags 11, Document 7, taxonomy 4, Discover 3). None remain, and the gate now holds both words out. - The API was split too, and is now aligned:
hierarchy_requests.pydescribed a field as "Documentação da gaveta para o curador" (a Portuguesedescriptionin the contract) and two worker messages said "formar clusters semânticos". Both saycategoria/agrupamentosnow, and the contract was regenerated —openapi.jsonandschema.d.tschange by exactly that one description. - What did not move: the screen count, the menu count, the settings tables, the data model and the API's shape — 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.
- Not carried, on purpose: the casing of the labels and hints. 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 Precisa de Mim Hoje", "Não É Assunto"), which the pt-BR convention does not; the preview of that direction is in the pull request rather than in this commit.
2026-10-10 — the documentation site, published from CI and dressed in the identity¶
- Range:
571c6cb..236bc58 - Pages:
guides/operate.md— updated: the new The documentation site section (the deploy path and the recovery, ADR 0012), plus the vocabulary the drawer round left behind —drawerfour times and onegavetainside the measurement SQL of an English page.ci.ymlandmkdocs.ymljoined itssources:, so a change to the publish path reports the page stale.guides/operate.pt.md— updated: the same section, and the same fivegaveta.guides/curate.md— updated: the sign-in plate is no longer centred and carries no tagline (bd89df2, in this range), and the warning still said"sem gaveta"in the canonical page while its own translation said"sem categoria".guides/curate.pt.md— updated: the identity paragraph, which carried the same "centralizado" that stopped being true, in the same sentence.guides/data-model.md— updated:drawerin five places, including the section heading and thearchive_macro_categoriesrow.guides/data-model.pt.md— updated: the same five, in Portuguese.- The report named two pages; the round touched six, and that is the finding. The freshness report
is computed from
sources:, so it can seeLoginForm.tsxmoving undercurate.mdand the worker strings moving underoperate.md— it cannot see a word that no source file owns. The previous round renamed the concept incurate.mdand its translation and stopped there:operate.md,data-model.mdand both translations still saiddrawer/gaveta, and one line ofcurate.mdkept the old string after the paragraph around it was rewritten. The check that found them wasgrep -rn "gaveta\|drawer" docs/guides/, run by hand, and it is worth running after the next rename — nothing in the suite would have failed. - A correction in the ledger itself, in this diff. The entry above ended its range at
HEAD, which_RANGEcannot parse, soledger_floorfell back to the range before it and the report re-flagged pages that round had already triaged. It now names the commit it meant (571c6cb), and this entry starts where it ends. - The gate this change adds, and why it is a test.
mkdocs build --strictexits 0 whentheme.logo,theme.faviconandextra_cssall point at files that do not exist — measured, not assumed.testing/unit/docs/test_docs_brand.pytherefore pins the wiring (an asset named and not present fails), the copies (byte for byte againstapps/curator/, so the identity cannot drift), the palette (against the tokens ofapps/curator/src/styles.css, read out of that file rather than repeated) and theurl(...)the stylesheet reaches for. - Not carried, on purpose:
guides/install.md,index.mdand the guides not named above — nothing in the range touches what they document, andadr/is not a sourced surface (ADR 0010).
2026-10-10 — the worker settings leave the operations panel (issue #53)¶
- Range:
236bc58..89e6a43 - Pages:
guides/curate.md— updated: a new Configuração dos Workers —/configuracoes/workers, and Workers de IA reduced to the machine view — the queue and running one now — with the effective configuration explicitly read only. The Configurações table and the Operação card row gain the fourth screen.guides/curate.pt.md— updated: the same two sections, in the SPA's vocabulary.guides/operate.md— updated:GET /api/v1/system/workers/settingsjoins the route table, the SPA table gains/configuracoes/workers, and the polling paragraph says why the new read exists — it resolves the same configuration and counts no queue. Theci.ymlchange of41fffc7(droppingconfigure-pages) is no-change here: the page describes the artifact and the deploy job and never named the dropped step.guides/operate.pt.md— updated: the same two tables and the same paragraph.- Why the round exists: issue #53 split a screen that answered two questions — what the machine is
doing and what the installation is set to do. The API gained exactly one read
(
WorkerOperationsService.list_settings, over the same_resolved_workerstraversal the panel uses) because the panel'sGET /system/workerscounts nine queues and the configuration screen shows none of them. The precedence (explicit argument > the archive_worker_settings row > the signature default) and the refusal ofconfigas an option are unchanged; the split moved where the write is made, never what it means. - What did not move: the data model and the migrations — no table, no column, no migration and no
settings field.
packages/api-contract/openapi.jsonandschema.d.tsmove by exactly one route and its two schemas (SystemWorkerSettingsResponse,WorkerSettingsItemDTO). - Verified, rendered: the built SPA served by the API on a migrated throwaway database, looked at
with the headless shell.
/configuracoes/workersrenders its heading and the nine cards (each withpadrão do códigoand the effective engine/preset/config), and expanding the NER card shows the Engine and Preset selects, the batch field, the JSON options field and Salvar padrão / Voltar ao padrão do código, with no runtime exception./sistema/workersrenders the same nine rows with only Rodar agora — no Configurar — and the engine and preset still on the collapsed row. The throwaway database's schema was dropped and the server stopped after the check. - Not carried, on purpose:
guides/install.md,index.mdandguides/data-model.md— the round adds no setting field, no table and no install step, and install's sources did not move.
2026-10-10 — the pre-release pass for 1.1.0¶
- Range:
89e6a43..b291d36 - Pages:
guides/install.md— no-change: the round moves the version inpyproject.tomland the curator'spackage.json, and this page documents the versions of the tools (Docker,uv, 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 frompyproject.tomlat build time and shown in the page footer, not written into this page.- Why the round exists: milestone 1.1 closed, so this is the release preparation the release
section of
CONTRIBUTING.mdasks for in one pull request — the version, the changelog, and the claims that had drifted while no gate looked at them. - What the report cannot see, and what found it. The freshness report is computed from
sources:, so it sawpyproject.tomlmove underinstall.md; it cannot see a page with nosources:(README.md,docs/index.md) and it cannot see a number 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 ofrouter.tsxand its status line still said1.0.0;guides/curate.mdsaid 25 screens whererouter.tsx,ScreenIdandSCREENSall carry 26 (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:grep -rn "gaveta\|drawer" README.md docs/guides/— the README still saiddrawerfive 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:draweris a word, and nosources:list owns a word. - The one number the release moved that no page owned.
info.versionwas Litestar'sDEFAULT_OPENAPI_CONFIG—title="Litestar API",version="1.0.0"— becausecreate_app()never passed anopenapi_config; measured with the project already at 1.1.0, the served configuration was unchanged.api/openapi.pynow derives both from the project, andtesting/unit/api/test_openapi_metadata.pyfails when the served document, the installed distribution,pyproject.tomland the committedopenapi.jsondisagree.guides/curate.mdis 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. - Not carried, on purpose: the ADRs.
0010still records "24 screens inrouter.tsx" as the measurement it was ratified against, and0012still 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.clusteralso stays, in the README's Discover row and in the guide: it is the API's own word for a proposed grouping (clusters_found,TRIGRAM/PLURAL/MIXED), which is the distinction the rename round drew and this one keeps.
2026-10-10 — the collection pagination¶
- Range:
b291d36..b99408e - Pages:
guides/curate.md— no-change: the Lista e busca —/acervo/listasection 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.guides/operate.md— no-change: the/sistema/execucoesrow 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.- Why the round exists: the freshness report named both pages after
e89bfbb/b99408etouched four route files. Twono-changeverdicts 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. - What the round measured.
/acervo/listabuilds its request from the URL (CollectionRoute.tsx,toRequest), so the page's claim was true of the read and false of the two buttons; the gate that keeps it true istesting/unit/curator/test_curator_search_state.py, which fails on an object that spreadschangesand then writes a literaloffset. The three other screens with the same fold (DeletionsRoute,SystemRunsRoute,DiagnosticsRoute) are normalized in the same change.
2026-10-12 — the entity merge and the normalized identity¶
- Range:
b99408e..c1a6fbf - Pages:
guides/curate.md— updated: the Entidades —/entidades/listasection 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 (#1167 A. B. Deter ≈ #92610 a. b. deter). 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.guides/data-model.md— updated: the Subjects, categories and entities table marksarchive_tags.nameandarchive_entities.nameas unique, which is still true, and said nothing about the identity rule the schema now enforces. One paragraph states it:lower(btrim(name))is unique on both tables next to the uniquename, and the insert path normalizes the spelling before it looks an existing id up.- Why the round exists: the freshness report named both pages after
eae9943andc1a6fbf, the two commits of issue #68 — the shared synonym upsert and the reconciliation migration. - What the round measured. The migration was replayed on a
pg_dumpcopy 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(description_id, survivor)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, andalembic checkreports 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),basedpyrightand that measurement.
2026-10-12 — the tag decision queue and the category expansion¶
- Range:
c1a6fbf..820f3c9 - Pages:
guides/curate.md— updated: the Tags route had four tabs and the page said so ("One route, four questions"). The fifth, Classificar, is the subject decision itself, so the page gains a subsection for it: theGET /tags/catalogread and itssubject_statepartition, the three destinations of one tag (PATCHinto a category,PATCHwithnull,PUT/DELETEon the facet), the durability of "not a subject" throughdomain_subject_exclusions, and the fact that the selection writes the same three. The Categorias section gains the expansion it never had: the two tabs of a card, the list read throughmacro_category_idwith the badge and thetotalbeing one definition, and the read-only preview with itsEditar. The summary table's two rows name the new decisions.guides/data-model.md— updated: 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 (PUT /tags/{id}/facets/{facet_type}, undone byDELETE) and the two things it settles with it: the drawer it empties and the subject exclusion it registers.guides/install.md— no-change: its only source in the range isSetupForm.tsx, 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.guides/operate.md— no-change:e2ecd04memoizes the once-only log sink (the page describes what is written, not how the sink is held),4ff8144reads the URL alphabet ofworker_nerfrom the RFC (the page documents the panel and the ledger, never the character classes),bc7fc07only gives the port stubs a body that has an effect, and from #64 the page's sources gainedTagCatalogStateinmodels/enums.pyand onevalidateSearchinrouter.tsx— a subject-axis filter and an SPA route, neither of which the operations panel reads.- Why the round exists: the freshness report named four pages after 30 commits landed on
devsince the last entry, three of them the work of issues #64 and #65, and it printed the three new operations (GET /tags/catalog,PUT/DELETE /tags/{id}/facets/{facet_type}) besideguides/curate.md. - What the round measured on the collection. On the reference collection (
memoriacuritibana: 8 103 tags, 55 345 links, 5 subject exclusions, 0 facets) the partition holds and the numbers are those of the change:PENDING7 407 +NOT_SUBJECT3 +FACET0 = 7 410, exactly the tags withmacro_category_id IS NULLthat the card used to count. So the card's number barely moves today — 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 twoNOT EXISTSread 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 withlimit 25over the grouped link count. Neither is a home-screen cost. - What the round looked at. The two screens were looked at, not inferred: the built SPA was
rendered with the dev stack (Vite on
:5173, 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 withgetComputedStyle:colorandbackground-colorwere bothrgb(255, 255, 255)before the fix andoklch(0.24 0.012 60)on white after it. The rest of what the pixels confirmed:PENDINGlisted 4 tags with the classifier's confidence beside each,FACETlistedrua exemplowith the Lugar 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'sEditartab rendered the form withSalvardisabled while nothing was dirty. Not verified: the collection's own screens. The API was pointed at the zeroed database, so the pixels are of a seeded scratch collection, not ofmemoriacuritibana; what was measured there was the SQL, read throughpsql, not the screen.
2026-10-12 — the review of the decision queue (no page changed)¶
- Range:
820f3c9..b759393 - Pages:
guides/curate.md— no-change: the range is the review of the change the previous entry documented. The offset clamp answers 200 where the route answered 500 (offset=-1), the contract pair moved only in the docstring ofTagCatalogState, and the four curator fixes changed the search box's debounce guard, the counts rendered whileplaceholderDataheld 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.- Why the round exists: the two
fixcommits landed after 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.
2026-10-13 — the collision batch¶
- Range:
b759393..0fd528e - Pages:
guides/curate.md— updated: the collisions screen gained the category filter (entity_type, the same read the batch selects with) and the batch itself,POST /api/v1/taxonomy/conflicts/resolve/batch. A new subsection, Deciding a category at once, says what the unit of the write is (the pair, never the selection), whyskippedis notfailed, and whyremainingis the same read taken twice. ThePendentesbullet 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.- Why the round exists: 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,
POST /api/v1/taxonomy/conflicts/resolve/batch, besideguides/curate.md. The range also carries three commits no earlier entry had closed (1f28807,7012898,e745466); the report named no page for them, so they are closed here by absence. - What the round looked at. The screen was looked at, 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 (
todas as categorias/Local/Pessoa/Organização) withnomes idênticosselected; 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?tipo=exact_name&categoria=LOCthe header, the card and the list move together — 36 collisions, allLocal— which is the selection the batch writes. Not 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.
2026-10-13 — the entity merge ledger (issue #34)¶
- Range:
0fd528e..318f8e0 - Pages:
guides/curate.md— updated: the Mesclar bullet of Entidades —/entidades/listasaid entities had "no proposal catalogue and no ledger", so there was no undo and no preview route. It now names the preview (POST /api/v1/taxonomy/entities/merge/preview, the plan the write executes), the ledger (GET /api/v1/taxonomy/entities/merge-log, one row per absorbed entity) and the undo (DELETE /api/v1/taxonomy/entities/merge-log/{merge_id}), and the summary table's row says the merge is reversible. The entity merge also left the What has no way back warning: it is no longer one of the writes that announce they have no undo.guides/data-model.md— updated: the subjects-and-entities table gained thearchive_entity_merge_logrow — one row per absorbed entity, snapshotted before anything changes, and whatundo_entity_mergerestores from it.- Why the round exists: 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
guides/curate.md:POST /entities/merge/preview,GET /entities/merge-logandDELETE /entities/merge-log/{merge_id}. The range also carriesa63b6c9and402d657(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. - What the round measured. 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 (
created_link_ids) and puts back the canonical name the optional rename changed; the preview and the apply are asserted to produce the same numbers; and thepair_kindpartition of the conflict routes, which this change must not disturb, keeps its own tests green. Measured: 1 511 tests passed,basedpyright0 errors,alembic checkreports no drift after the new table. - What the round looked at. The screen was rendered, not inferred: the built SPA was served by
the API (
create_app()mountsapps/curator/dist) against the reference collection, with a scratch session created for the round and deleted after, and the headless shell drove/entidades/listaover 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 smu · 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 Ledger dos merges de entidades section at the bottom, with its server-side search box and the empty state ("Nenhum merge aplicado ainda"). Not 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.
2026-10-13 — an entity opens into its descriptions (issue #86)¶
- Range:
08500a9..HEAD - Pages:
guides/curate.md— updated: Entidades —/entidades/listagained the read half of the screen, which it never described. A name expands into the descriptions that carry it (GET /api/v1/documentswith the newentity_idfilter, paged ten at a time inside the card); the badge above and the list'stotalare one number counted twice, pinned bytesting/integration/api/controller/test_entity_documents.py; a row opens the read-only preview the category screen already uses, and its "Editar" lands on the dossier's Assuntos tab, which is where a description's entities are added and removed.?aberta=,?offset=and?descricao=are named as the state the URL carries.guides/curate.pt.md— updated: the translation of the same paragraph.- Why the round exists: 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
guides/curate.mdfor the SPA and API commits of this change. - What the round measured. The pairing was measured on the reference collection, through the
routes the screen calls and not against a fixture:
entity_id=10(Curitiba) answerstotal=3152where the card showstotal_usage=3152, and2679/2670answer2467against2467. Measured: 1 513 tests passed,basedpyright0 errors,alembic checkreports no drift (the change adds no migration), and the SPA'stsc, ESLint and Vite build are green. The new test seeds one description carrying two entities on purpose, so the join it forbids would answer 2 where the card promises 1 — the same traptest_category_documents.pypins for the drawers. - Found and deliberately not fixed.
EntityRepository.get_relevance_countinner-joins the link table, so the ranking never lists an entity that no description carries:EntityRow's orphan branch, and the per-row Excluir 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 intest_an_orphan_is_not_ranked_and_its_expansion_is_empty. - What the round looked at. The screen was rendered, not inferred: the built SPA served by the
API (
create_app()mountsapps/curator/dist) 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:/entidades/lista?aberta=10with the Curitiba card open, the badge "3.152 descrições" above the expansion's own "1–10 de 3.152", the controls (checkbox,tipo, Excluir) 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 sharedDescriptionPreview; and/assuntos/categorias?aberta=4still rendering its own expansion after the component was lifted out ofCategoriesRoute.tsx("2.312 descrições" against "1–10 de 2.312"). Read from the DOM and not inferred: the entity panel's Editar carrieshref="/acervo/53617?aba=assuntos", and the category panel's still carries?aba=descricao. Not 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.
2026-10-13 — renaming a name, with an undo (issue #85)¶
- Range:
6c79880..HEAD - Pages:
guides/curate.md— updated: Entidades —/entidades/listagained its fourth decision, Renomear (PATCH /api/v1/taxonomy/entities/{entity_id}), with the three facts the screen has to state: the previous spelling stays anchored as a synonym unlesskeep_old_synonymis turned off, the write leaves oneRENAMErow in the merge's own ledger so the ledger's "desfazer" reverses it, and a spelling another entity already carries is a 409 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.guides/curate.pt.md— updated: the translation of the same.guides/data-model.md— updated: thearchive_entity_merge_logrow now says the table holds a merge (one row per absorbed entity) or a rename (one per name), and nameskindas the checked discriminator the undo branches on.guides/data-model.pt.md— updated: the translation of the same.- Why the round exists: 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 inside
the merge panel, so a plain typo had no path that did not absorb another entity. The freshness
report named
guides/curate.mdfor the API and SPA commits of this change. - What the round measured. Measured: 1 524 tests passed,
basedpyright0 errors, and the migration applied, reversed and re-applied on the reference database withalembic checkreporting no drift. The write and its reverse are pinned by the integration suite against the real repository: the rename leaves exactly oneRENAMErow (no absorbed entity, no links), the undo restores the name and drops the spelling the rename planted,keep_old_synonym=Falsewrites no spelling at all, the collision raisesDuplicateEntityNameErrorwith the twin indetailsbefore the write, and a merge with a rename leaves one row per absorbed entity and noRENAMErow — the trap the shared writer would otherwise have created.refusalMessageis what turns that payload into the sentence, so the copy the archivist reads is the interface's, not the server's. - What the round looked at. The screen was rendered against the reference collection
(
scrinalia_zeroed, 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:/entidades/listawith the row's Renomear inactions, outside the toggle, and the card opened by it on the Editar tab, with the name input and the "manter a grafia antiga como sinônimo" box checked; the same form refusingcuritibawith "Já existe uma entidade chamada \"curitiba\". Mescle as duas em vez de renomear." and the request reference, with the row still readingpaço da liberdade— the refusal happened before the write; and the ledger card, reading "paço da liberdade → paço da liberdade de curitiba · renomeação · 13/10/2026, 15:21 · por Cassio", which is the rename applied through the route on that collection. The desfazer was then clicked on the screen, and the ledger row came back as "desfeito em 13/10/2026, 15:23" with the row readingpaço da liberdadeagain; 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.
2026-10-13 — the merge card fed by the type-ahead (issue #87)¶
- Range:
e0c0720..bbd0189 - Pages:
guides/curate.md— updated: Entidades —/entidades/listanow says the merge has three doors — the selection on Relevância, a pair on Similaridade and the Mesclar pelo nome 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.guides/curate.pt.md— updated: the translation of the same.- Why the round exists: 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
guides/curate.mdfor the SPA commit. - What the round measured. Measured: 1 528 tests passed,
basedpyright0 errors, the SPA'stsc, ESLint and Vite build green, andmkdocs build --strictclean. The acceptance pair was measured against the reference collection before it was rendered:smu(entity 45) carries 68 descriptions andsmcs secretaria municipal de comunicação social(entity 349) carries 1, so the relevance ranking — capped at fifty rows, whose lightest entry carries two — can never show both; andsimilarity('smu', 'smcs secretaria municipal de comunicação social')is 0.045, far under any threshold the similarity tab accepts. Neither list reaches the pair; the card does.testing/unit/curator/test_entities_merge_doors.pyis the gate for the shape: it fails when the write gains a second call site, when a door stops rendering<MergePanel, or when the card starts calling the write, the preview ornavigateitself. It is a textual gate and says so — the SPA has no test runner of its own. - What the round looked at. The screen was rendered 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 collapsed between the irreversibility
Noticeand the tabs, with the labelled "+ Mesclar pelo nome" button; and the card open 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 real 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. Not 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.
2026-10-13 — the SPA stops re-declaring the contract's query shapes (no page changed)¶
- Range:
e0c0720..HEAD - Pages: none, and that is the verdict this entry records. The change lives inside
apps/curator/src/api/, and no page declares that path among itssources:— the freshness report named no page for it. What changed is a rule file,.dsh/skills/curator-ui/SKILL.md: it now states that a query shape is derived from the route and never typed out, with the measured failure beside it. - Why the round exists: the last change to this surface (#86) added
entity_idto the contract and had to add it to the hand-written mirror by hand. Sizing that mirror found it had already 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. - What the round measured.
DocumentSearchdeclared 14 keys against the route's 15, missingis_anomaly, and nothing failed:CollectionRoute.toRequestspreads a widerCollectionSearchinto it, the extra key survives the return type, andopenapi-fetchsent 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:GET /api/v1/documents?is_anomaly=trueanswers 28,?is_anomaly=false73 (28 + 73 = 101, the whole collection), and a misspelled?is_anomalyy=trueor a nonsense?non_sense=42answers 101 with HTTP 200 and no warning — 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, andtsc, ESLint, the build and the suite green. 19 shapes derive from their route now (three of them named), and the ten call sites that build a one-key value inline —query: { only_active: true },query: { term, limit }— 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),tsc, ESLint and the Vite build green. The derivation needed no reinterpretation — the generated type already spells an omitted filtercampo?: T | null— and an earlierExclude<T[K], null>mapping was measured to be both unnecessary and wrong (it rejected two callers that legitimately holdnull). - What the round looked at. The screen was rendered:
/acervo/lista?is_anomaly=trueagainst 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 "28 descrições" with the chip "Somente anomalias ✕" and every row carrying itsanomaliabadge — the same 28 the route answers, now filtering through a field the type declares instead of one it denied.
2026-10-13 — the stopword purge stops being the write with no way back (#36)¶
- Range:
f0ba7c2..HEAD - Pages:
guides/curate.md— 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)guides/curate.pt.md— updated (the translation of the same three passages)guides/data-model.md— updated (archive_stopword_purge_logjoined the ledger table and the list of tables that carry an author pair).dsh/skills/taxonomy/SKILL.md— updated (the rule file said the purge "has no undo"; it now states the plan/apply/undo, thesynonym_namessnapshot and the ban that the undo must not lift)- Why the round exists: 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.
- What the round measured. The round trip through the routes, against
scrinalia_zeroed: a scratch tag1924carrying 2 documents,POST /tags/stopwords/purge/previewansweringreversible=true, total_tags=1, total_documents=2, the purge answeringtags_deleted=1, purge_ids=[1], andDELETE /tags/stopwords/purge-log/1putting the row and both links back whiledomain_stopwordskept1924 | TAG. The scratch tag, the ledger row and the scratch session were deleted after the round. Measured: 1540 tests passed,ruff check/format --checkandbasedpyright(0 errors) green,alembic checkreporting no drift, the contract regenerated and stable, andtsc, ESLint and the Vite build green. - What the round looked at. The screen was rendered:
/assuntos/tags?aba=stopwordswith the scratch session, the headless shell driven over CDP. Seen in the pixels: the notice reading "Como o merge, ela guarda um ledger", the card "Ledger das purgas" with the row "1924 · 2 doc(s) · por Cassio" and its desfazer button, and — after clicking it — the same row showing "restaurada em 13/10/2026, 18:13". Not 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).
2026-10-13 — the API enforces the temporary password (#79)¶
- Range:
bbd0189..feed903 - Pages:
guides/curate.md— 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)guides/curate.pt.md— updated (the translation of the same paragraph)- Why the round exists: 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 API, not the
screen, owns the refusal changed what is true on a page that documents the guard
(
src/scrinalia/api/security.pyis in itssources:), which is why this is aupdatedand not ano-change. - What the round measured. The route through HTTP, against the test database: an account with
must_change_password=Truesigned in,GET /api/v1/curation/inboxanswering 403 witherror_code: "PasswordChangeRequiredException"andcode: "PASSWORD_CHANGE_REQUIRED",GET /auth/meandPOST /auth/logoutanswering 200, and the same inbox answering 200 afterPOST /auth/password. Before the fix the same inbox answered 200 with the flag set — the defect, measured in this checkout before the guard changed. Measured: 1550 tests passed,ruff check/format --checkandbasedpyright(0 errors) green, and the contract regenerated with one additive enum member (PASSWORD_CHANGE_REQUIRED) and the committed client rebuilt from it. - What the round looked at. No screen was rendered, and none needed to be: no SPA file
changed, the forced screen already reads the flag from
/auth/meand calls/auth/password, and this change moves the enforcement behind that screen. Not looked at in a browser; the evidence is the integration test over HTTP, the unit guard test and the regenerated contract.
2026-10-13 — the scrape and the transform can be bounded per run (#47, #48)¶
- Range:
feed903..84bb5f0 - Pages:
guides/operate.md— updated (the pipeline table said "Nine workers, in pipeline order" and started attransfer; 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--after)guides/operate.pt.md— updated (the translation of the same two pieces)guides/install.md— updated (ACERVO_SOURCEalso selects the origin's ports now, and--originoverrides it for one run, which is that row's business)guides/install.pt.md— updated (the translation of that row)- Why the round exists: the report named
guides/install.md, whosesources:includeingestion/sources.py, because that module gained the origin's port registry.operate.mdwas not 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. - What the round measured. Full suite 1574 passed (
uv run pytest, withTEST_DATABASE_URLpointed at the test database on 5434),ruff checkandruff format --checkgreen,basedpyright0 errors,alembic checkreporting no new upgrade operations (no schema change),mkdocs build --strictgreen. The batched run was measured on a throwaway database — created on the test server, migrated withalembic upgrade head, seeded through the repositories and dropped afterwards — because the reference collection cannot reproduce the issue's "queue of thousands": measured there,scraping_queueholds 100 rows, allDONE, with 100raw_data, 100staging_documentsand 101archive_documents, and nothing pending to transform. On the scratch database, with a 250-row queue and a port double in place of the site:--limit 100took 100 and left 150, then 100 and left 50, then 50 and left 0; 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. - What the round looked at. No screen was rendered, 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 no scrape against the origin site was run, and the numeric evidence above is synthetic in that sense. That is the gap this entry declares.
2026-10-13 — the NER purge stops being the write with no way back (#94)¶
- Range:
84bb5f0..95796c3 - Pages:
guides/curate.md— updated (the NER veto no longer claims to reach backwards: it bans and deletes nothing; the retroactive half is Expurgar do passado, with its preview, itsarchive_ner_purge_logand 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)guides/curate.pt.md— updated (the translation of the same section)guides/data-model.md— updated (archive_ner_purge_logjoins the ledger table, the conflict ledger row names the spellings it now restores, anddomain_ner_exclusionssays the row itself deletes nothing)guides/data-model.pt.md— updated (the translation of those rows)- Why the round exists: the report named
guides/curate.mdandguides/data-model.md, 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. - What the round measured. Full suite 1591 passed (
uv run pytest, withTEST_DATABASE_URLpointed at the test database on 5434),ruff checkandruff format --checkgreen,basedpyright0 errors,alembic checkreporting no new upgrade operations after the new migration was applied,bun run --cwd apps/curator typecheck lint buildgreen, andmkdocs build --strictgreen. 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. - What the round looked at.
/entidades/excecoeswas rendered on the dev stack (the API onscrinalia_zeroed, 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 Expurgar do passado card with its preview button, the Ledger dos expurgos 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 temporary orphan entity (misto,ORG) 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.
2026-10-13 — the tag axis is the subject axis (#83, #84)¶
- Range:
95796c3..32d19f1. The range ends at the second of the two commits that carry the change (the API/domain one is5055312, the curator one is32d19f1), so the floor closes the whole change.guides/operate.mddeclaresmodels/enums.py, which moved, so it was read and not skipped. - Pages:
guides/curate.md— updated: the Classificar tab has two destinations now — a drawer, or not-a-subject — instead of three. TheFACETrow leaves the state table, the row's evidence loses the facet, and the producer/place/person cases move to the entity axis (a typeLOC,PER,ORGthat search and the public projection read) and to the tag × entity conflict screen; thePUT/DELETE /api/v1/taxonomy/tags/{tag_id}/facets/{facet_type}routes are gone. The Não é assunto section stops promising that "only the subject classification stops guessing": confirming also bans the spelling atTAGscope, the ban deletes nothing, and the purge stays a deliberate act behind its preview and ledger. It documents the new read-onlyPOST /api/v1/taxonomy/tags/subject-exclusions/preview(AUTHENTICATED) with itsapplicable/blocked, the refusal of a term whose tag is already in a drawer (SUBJECT_EXCLUSIONS_BLOCKED/SUBJECT_EXCLUSIONS_PARTIAL,blocked,created,banned), and the undo'sremovedandunbannedlifting only the ban this decision planted. The Stopwords tab and its purge preview now carry the ban'ssource(HUMAN/CONFLICT/EXCLUSION).guides/curate.pt.md— updated (the translation of the same three places).guides/data-model.md— updated:archive_tag_facetsleaves the subject table (a tag is a drawer or not-a-subject; what a place, a producer or a person is belongs to the entity axis),domain_stopwordsgainssourceanddomain_subject_exclusionsgainsban_created, the bidirectional-governance section separatessave_stopwords(moves the scope) fromensure_tag_ban(widens and never narrows), and the ledger note saysban_createdbelongs to both writers of a ban.guides/data-model.pt.md— updated (the translation of the same).README.md— updated: the front door still said a provenance-or-geography tag carries anarchive_tag_facetsrow; 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.)guides/operate.md— no-change: themacro-categoryworker's unit and its pending rule (is_subject_candidate+ the exclusions) never read the tag catalogue's states, and the diagnostic SQL countsmacro_category_id IS NULL, which the dropped table never entered. The page declaresmodels/enums.py, which moved, so it was read and not skipped.adr/index.md— the implementation change already links ADR 0013; the coverage gate's ADR check is green.adr/0008-...mdwas updated in the same commit: it still saidis_place_term"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 anAmended by ADR 0013blockquote and named in ADR 0013'sAmends:.- Why the round exists:
freshness.pyprinted "Every page is fresh" 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 movescurate.md's declared sources (api/controllers/**,services/tag_service.py,repository/tag_repo.py,models/enums.py,packages/api-contract/openapi.json, and the SPAroutes/**) anddata-model.md's (models/**,repository/tag_repo.py). The API delta is two routes removed and one added. - What the round measured.
uv run pytest testing/unit/docs -q— 28 passed;testing/unit/docs/test_documentation_coverage.py— 7 passed (screens, tables, settings, workers, ADRs and the API-resource check);uv run mkdocs build --strictgreen. The full Python suite and the SPA build belong to the round that lands the change and were not re-run here. - What the round looked at. The screens were rendered, not inferred. The built SPA was served
by the API (
create_app()mountsapps/curator/dist) 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: Classificar (/assuntos/tags?aba=classificar) with the four chips —Todas,Sem decisão,Não é assunto,Confiança baixa, noFACET— the row'sDecidircolumn 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. Não é assunto (/assuntos/excecoes) with "4 candidatos · 4 são lugares (o eixo de entidades os carrega)", theé lugarbadge 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. Stopwords (/assuntos/tags?aba=stopwords) with the origin badge on the terms the backfill classifiedCONFLICT(veio de um conflito tag × entidade) and no badge on theHUMANones (1924,1926), which is the 83/5 split the migration produced. The blocked branch was rendered too, by putting one candidate tag (ruas, id 131) intoMobilidade e Transporteand shooting again: the row gained theestá em Mobilidade e Transportebadge and its button went disabled; the mutation was reverted immediately and the pending count returned to 204. Not 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 realban_created/sourcecolumns).
2026-10-13 — the thumbnail worker runs against a local endpoint (#33)¶
- Range:
480fd36..153afb2— the merge that carried the previous round, then this change. The change was uncommitted during the triage, sofreshness.pyprinted "Every page is fresh"; the triage was driven by the working-tree diff, as the previous entry's was. - Pages:
guides/install.md— updated: the settings table gainsS3_AUTO_CREATE_BUCKET(defaultfalse) and theS3_BUCKET_NAMErow stops saying the bucket "must already exist" without qualification; the Object storage is not shipped section now says the worker confirms the bucket once, before the queue, creates it only with the flag on, and that SeaweedFS answersAccessDeniedto an upload into a bucket that does not exist — which is why the confirmation exists. It also states thatGET /system/healthreports the distinction and never creates anything.guides/install.pt.md— updated (the translation of both places).guides/operate.md— updated: the runner's flag table gains--limit N(a cap on the units of one run, honored by the workers that declarelimitand refused by the others) and now says that--batchis logged when the worker does not declaredb_batch_size; thethumbnailbullet names the bucket confirmation and the cap.guides/operate.pt.md— updated (the translation of the same).- Why the round exists: the change moves
core/config.py(the install page's declared source) andcore/storage.py,api/system_health.py,workers/runner.pyandworkers/worker_*.py(the operate page's), so both guides were read and both needed a text. - What the round measured. Full suite 1608 passed (
TEST_DATABASE_URLpointed at the test database on 5434),ruff check/ruff format --checkgreen,basedpyright0 errors,alembic checkreporting no new upgrade operations,mkdocs build --strictgreen. Against the local SeaweedFS and the reference collection the worker went from 0/97 to 97/97 descriptions withstorage_thumbnail_uri, 97 objects underthumbnails/, 0thumbnail_failed, and aSUCCESSrow inarchive_worker_runs(id 14,trigger=CLI, 55.5 s). The refusals were exercised:--limit 2processed exactly 2,--limit 2onnerwas refused with the list of workers that declare it, and--batch 2onthumbnaillogged that the value was ignored.S3_AUTO_CREATE_BUCKET=truecreated a scratch bucket on the endpoint (deleted after the measurement), and the flag-off path refused with the bucket and theaws s3 mbcommand named. - What the round looked at. No screen was rendered, 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. Not verified in pixels: the Diagnóstico storage card, which the probe's two
branches feed — those branches are pinned by
testing/unit/api/test_system_health.py. - Divergence the round surfaced. The CLI could not write its own ledger row:
archive_worker_runscarries a foreign key intoauth_users, and a process that imported only the archive models failed to flush the insert withNoReferencedTableError; 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:scrinalia.metadata.load_all_modelsis the one definition of the model packages, andmigrations/env.pyandWorkerRunLedgerboth call it.
2026-10-13 — the pre-release pass for 1.2.0¶
- Range:
153afb2..HEAD. The round was uncommitted during the triage, sofreshness.pyprinted "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 (153afb2) — the entry that closed the thumbnail round and its test — so no page'ssources:had moved when the reading began, and none of the drift below was a source delta either. - Pages:
README.md— updated: the status line moves to1.2.0; the no-undo paragraph, which named three writes that had since become reversible; the runner snippet gains--limit N;/sistema/workersis read-only for configuration since #53; the unauthenticated list gains the first-run routes; theworker_*.pyclaim gains its exception.docs/index.mdanddocs/index.pt.md— updated: "Scrinalia is at 1.1" → "at 1.2".guides/install.mdandguides/install.pt.md— updated: the twoSistema › …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'ssources:gains the SPA navigation files it states facts about (AppShell.tsx,lib/screens.ts,lib/settings.ts), which the freshness report could never flag before.guides/operate.mdandguides/operate.pt.md— updated: the workedconflict-judge --option host=…example, which the runner refuses (measured:InvalidWorkerSettingsError); thecleaningunit cell; the claim that the CLI formats the duplicate-run refusal the way the API does; thereset-password --passwordcomment.guides/curate.mdandguides/curate.pt.md— updated: 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 Trechos row, which called the removal reversible.guides/data-model.mdandguides/data-model.pt.md— updated:archive_entity_merge_logjoins 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;NOT_PUBLIC_FIELDS;archive_tagsunder the retire-with-is_activerule; "both merges".CHANGELOG.md— updated:## [1.2.0] - 2026-10-13replaces## [Unreleased], which carried two 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.mkdocs.yml— updated: ADR 0013 joins the site nav.- Why the round exists: milestone 1.2 is complete, so this is the release preparation
CONTRIBUTING.mdasks for in one pull request — the version, the changelog, and the claims that drifted while no gate looked at them. - How the pages were read. The freshness report names a page only when a declared source moved,
and it cannot see a page with no
sources:at all, nor a number or a sentence 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 silent 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 Trechos removal had never been reversible. - The gap no gate covered, now gated.
docs/adr/0013-the-tag-axis-is-the-subject-axis.mdexisted, was linked fromadr/index.mdand passed the ADR coverage check, and was absent from the site nav:mkdocs build --strictstays green because an unlisted page is anINFO, not a warning, so the ADR built and no menu reached it. The nav lists it now, andtesting/unit/docs/test_documentation_coverage.pygainedtest_every_adr_is_in_the_site_nav, which readsmkdocs.ymlas text — reachability is the nav, so the nav is what the gate reads. - A code defect the reading surfaced, and its gate.
NOT_PUBLIC_FIELDSlistedoriginal_thumbnail_url, which is not a field ofDocumentSummary(it lives onArchiveDocumentDTO), so the declaration withheld a field the internal summary never carried and the comment justified it as "the source link, superseded bystorage_thumbnail_uri". The entry is gone, andtest_every_declared_non_public_field_existsfails on the next name that is not a field: the partition test readsinternal − published − NOT_PUBLIC_FIELDS, so a dead entry was invisible to it. - Two defects left standing on purpose, both now documented. The conflict resolution's undo
guards on
ban_createdbut not on the ban'ssource, so it can lift a ban a person re-planted on the Stopwords screen — a real gap inEntityRepository._lift_conflict_ban, recorded in the data-model page rather than fixed here, because changing an undo's reach belongs to its own change. AndWorkerStamp("thumbnail")is declared and never written,worker_thumbnail's queue being led bystorage_thumbnail_uri; the page says so now. - What the round measured. Full suite 1610 passed (
TEST_DATABASE_URLpointed at the test database on 5434 — not 5433, which another project holds on this host; the compose file publishes${TEST_DB_PORT:-5433}, so 5433 would have talked to the wrong PostgreSQL),ruff checkandruff format --checkgreen,basedpyright0 errors,mkdocs build --strictgreen, and the CI contract check replicated by hand:uv run python -m scrinalia.api.dump_openapiis byte-identical to the committed contract atinfo.version1.2.0.uv.lockandbun.lockcarry the number and were regenerated, andtesting/unit/api/test_openapi_metadata.pypins the served document, the installed distribution,pyproject.tomland the committed contract together. - What the round looked at. No screen was rendered, and none needed to be: no SPA file
changed. The UI statements corrected here are navigation labels and counts, read from
AppShell.tsx,lib/screens.tsandlib/settings.ts— not observed in pixels, which is what a screen change would require. - Not verified, and said so. 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
ACERVO_LANGUAGEprofile, andinstall.pt.mdwas 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. - Not carried, on purpose: the ADRs.
0010still records "24 screens inrouter.tsx",0012still quotes the index's old "at 1.0", and0011still 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.src/scrinalia/api/openapi.py's docstring keeps "withpyproject.tomlat 1.1.0" — that is the date of a measurement, and changing the number would falsify the record rather than update it.
2026-10-14 — the held majors adopted on purpose (#40)¶
- Range:
3c108cd..0ec13bb - Pages: three were reported stale and all three hold.
guides/install.md— no-change: theDB_*table, the "DATABASE_URLis a derived property" paragraph and thedocker-compose.test.ymlsection still describe the code. The source this page declares that moved (pyproject.toml) is not quoted in its text, and the driver the DSN now names (postgresql+psycopg2) is not a setting an installer chooses.guides/data-model.md— no-change: no table, column or index moved.testing/conftest.pygained a third thing it builds beforecreate_all— 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.guides/operate.md— no-change: 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 —SELECT DISTINCT ON (archive_worker_runs.worker_name), and aRowthat no longer needsResult.tuples()— and the panel and queue-count tests that read them are green.- Why the round exists:
pyproject.tomlheld two majors behind a comment (pandas<3,sqlalchemy<2.1), and adopting them changes the environment every installation and CI gets. - What the round measured. The driver:
postgresql+psycopg2is spelled out incore/config.py,testing/conftest.pyand the CI environment, so the default cannot move under the project again. SQLAlchemy 2.1.4:Enum(..., create_type=False)stopped being ignored — the PostgreSQL impl ofdomain_stopwords.word_scopecomes outcreate_type=Falseon 2.1.4 and came outTrueon 2.0.54 — which cost the test schemastopwords_scope,anomaly_type_enumandscrape_status_enumand gave 851 errors at setup (type "stopwords_scope" does not exist) untilconftest.pycreated them from the models; ninebasedpyrighterrors answered at their sites;Result.tuples()and the expression form ofdistinct()removed, the latter measured asSELECT DISTINCT ON (archive_worker_runs.worker_name). pandas 3.0.6: the default string dtype is the newstrand 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);Series.__getitem__now returns a union that includes aSeries, and the twoint(row[...])sites answer with acast. Final: 1610 passed on a fresh test database with-W error::DeprecationWarning,basedpyright0 errors,ruff checkandruff format --checkgreen,alembic checkreporting no new upgrade operations, and the committed contract byte-identical todump_openapiby hand. - What the round looked at. No screen was rendered, and none needed to be: no file under
apps/curator/changed. - Not verified, and said so. The real engines were not run — the suite mocks the registry, so
bertopicwas 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.
2026-10-14 — the test schema also drops what it creates (#40)¶
- Range:
0ec13bb..3227e3d - Pages:
guides/data-model.md— no-change (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 withtype "anomaly_type_enum" already exists, becausedrop_allskips acreate_type=Falsetype.testing/conftest.pynow drops them afterdrop_all, from one generator shared with the create, and no table, column or index moved. - What the round measured. The CI sequence replicated on a fresh test database: 1610 passed,
pg_typeandpg_tablesempty after the teardown,alembic upgrade headapplying every migration, andalembic checkreporting no new upgrade operations.
2026-10-14 — the stamp names the producer (#41, #70)¶
- Range:
f240c71..52ed890 - Pages: three were reported stale by
docs/_hooks/freshness.pyand all three changed. guides/operate.md— updated: the queue table and the identity paragraph (five of the nine stamps now carry the producer), the--limitrow (six workers, notthumbnailalone), the--optioncoercion (the declared type, not justtrue/false), the reprocessing section (a new producer re-queues by itself;forcereachesnerandtypologyand clears theERROR), the new Trying a configuration before saving it section with the measured0→4.465, and the route table's preview row. The paragraph that recorded the old trap — "embedding_v1is the MD5 of the text, not the identity of the model, so changingtorchdoes not put a single description back in the queue" — was the defect this round removes, and it now says what?does and does not answer.guides/data-model.md— updated: theexecution_logkey table and the value-keyed paragraph (six keys, the identity, the two producers deliberately outside it).guides/curate.md— updated: theConferir impactobutton of/configuracoes/workers, and why the number clears when a field moves.- What the round measured. The forced-run defect, with a throwaway test (deleted) against the
test database:
quality-validatorwithforce=Truecounts the same unit after it is stamped (before=1 after=1), and its loop had no cursor, so the documented commandrunner quality-validator --option force=truenever ended.nerandtypologyadvance only because the stamp leaves the queue (before=1 after=0), which is exactly the clauseforcedrops. The identity itself:describe_configforcpu_localandgpu_clouddiffers only indevice, and the whole output is hashed on purpose. The suite: 1660 passed, 1 skipped (the skip is the conflict-judge's counter-parity case, which has no queue);basedpyright0 errors;ruff checkandruff format --checkgreen;alembic checkreporting no new upgrade operations; the committed contract regenerated fromdump_openapi. - What the round looked at. The settings screen was rendered: headless shell + CDP against the
dev server and a scratch database (
scrinalia_render, 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 answered0 na fila com esta configuração (nada foi salvo).; typing{"confidence_threshold": 0.9}into the options field and pressing Conferir impacto answered4.465 na fila com esta configuração (nada foi salvo).The screenshots are under.render/(gitignored), not in the repository. - Not verified, and said so. 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 real
collection were not measured; the
4.465above is the scratch database the render used. The installed versions are read fortorch,transformers,sentence-transformersandspacyonly: the spaCy model package (pt_core_news_lg) 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. - Consequence to expect. Every unit stamped before this change — including a
DONE— 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.
2026-10-17 — the bench family, and the rule axis with no rule to score (#114, #115, #116)¶
- Range:
52ed890..4436154 - Pages: three were reported stale by
docs/_hooks/freshness.py. All three are no-change, and the single commit that moved a declared source isef11773— the refactor the validator bench needed, which named three functions and a separator instead of changing behaviour. guides/curate.md— no-change: nothing on a screen or a route moved. The rules screen still offers the same threerule_kinds with the same three writes, and the paragraph that says aVALIDATE/LLM_CHECKrule only flags and never rewrites still holds. The change tomodels/enums.pyis a named constant plus two accessors for a format that already existed.guides/data-model.md— no-change: no table, no column and no constraint changed.anomaly_reasonskeeps its type and itsCODE:payloadcontent — the separator becameANOMALY_REASON_SEPARATORinmodels/enums.pyand the facet's SQL reads it instead of a literal":", which is one definition where there were two. Thearchive_cleaning_rulesrow and therule_kindparagraph are untouched.guides/operate.md— no-change: the worker table, the queue predicates and the identity paragraph describe behaviour that did not move.worker_quality_validator.pygainedmatched_rule_labels,llm_check_reasonandrule_flag_label, and_compile_rulesbecamecompile_rules; the worker marks the same documents with the same reasons and stamps the same producer.- What the round found. The validator bench needed "did this rule fire", which existed only inside
evaluate_document, 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 ananomaly_reasonrather than its name. - What the round measured. Against
memoriacuritibanaon 2026-10-16: the conflict judge agrees with the 49 human resolutions at 0.918 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 4,583 of 4,831 documents (94.9%), of whichRULE_MATCH/LLM_SUSPECTare zero: the catalogue holds one rule, inactive andREWRITE, so the per-rule axis has nothing to score and both benches refuse with that sentence instead of an empty table. - Not verified, and said so.
xlm_roberta_localwas 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. Thegranite_localconflict judge answered 500 to every prompt (granite4.1:3b, 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.
2026-10-17 — the typology queue is gated on the stamp (#117)¶
- Range:
4436154..22bfe62 - Pages: one was reported stale by
docs/_hooks/freshness.pyand it changed. guides/operate.md— updated: the queue table'stypologyrow 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; theforcebullet no longer claimstypologynever 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 every 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.- What the round measured. Against
memoriacuritibanaon 2026-10-17, and the numbers are the argument:count_pendinganswered 4,205 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 4,813. The 608 split into 135 with a typology stamp and 473 with none; the 4,223 without a typology split into 4,188 never seen and 35 seen and refused. - What the round checked. The pair is pinned by integration tests that read the real queue through
the worker's own
pending_conditions: 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. - Not verified, and said so. The re-classification of the 4,813 was not executed — 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:
gemma/mDeBERTaweights are not exercised here.
2026-10-17 — the typology bench compares label formats (#112)¶
- Range:
22bfe62..a554cbe - Pages: none.
docs/_hooks/freshness.pyreported every page fresh: the round touchedtesting/evaluation/typology_quality.py, 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. - What the round measured. The three formats on 111 of the 112 labelled rows, CPU, at the
shipping configuration
deberta_typology:cpu_local:original_title:0.40:bareagrees 0.775 with macro-F1 0.484 and 5.4% abstention;trata_de0.784 with 0.478 and 7.2%;sentence0.604 with 0.414 and 29.7%. The plans are wheresentencebreaks — recall 0.956 → 0.511, with 20 of them abstaining — which is #30's lost entailment reappearing exactly whereclassifier_labelswarns it does. - What the round decided. "Ofício / Memorando" takes exactly 8 of the 11 descriptions no
typology covers under all three formats: the dustbin is not a wording problem, and no label shape
makes an uncovered form covered.
barewins, 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. - Not verified, and said so. 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 not 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.
2026-10-17 — the typology records the refusal it saw (#110)¶
- Range:
a554cbe..7a40f14 - Pages: three were reported stale by
docs/_hooks/freshness.py; two changed and one is no-change, and the reason it is no-change is the honest part. guides/data-model.md— updated: thearchive_ai_review_queueparagraph said the queue carriesSUBJECT_LOW_CONFIDENCE"for tags the subject classifier could not place"; it now also namesTYPOLOGY_LOW_CONFIDENCEand says that row is where the score the run saw is kept, becausetypology_idstays NULL for a refusal and for a description no run reached.guides/operate.md— updated: thetypologyworker's bullet said only that it classifies by zero-shot; it now says what a refusal leaves behind.guides/curate.md— no-change: it moved only becausemodels/enums.pyis 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 #129: the descriptions the classifier refused need a screen, and that issue decides first whether it belongs to/arranjo/tipologiasor widens/qualidade/anomalias.- What the round measured and verified. The migration was applied against a scratch database and
against the development one:
upgrade headfrom3f9c1a7be204adds the value, the enum then lists all three, anddowngrade -1runs. The development database's revision matched this migration'sdown_revisionexactly, so nothing else moved with it.alembic checkreports no new upgrade operations. - Not verified, and said so. 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.
2026-10-17 — the typology bench simulates a catalogue edit (#111)¶
- Range:
7a40f14..5da851b - Pages: none.
docs/_hooks/freshness.pyreported every page fresh: the round touchedtesting/evaluation/typology_quality.pyand its unit tests, and no guide declares a bench as a source. The ledger entry exists to record the verdict and advance the floor. - What the round measured. 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" (0.856, 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).
- What the round concluded. On this axis the name is the taxonomy: "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.
- Not verified, and said so. 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
nullfor them — correct for the product, wrong for the placar, which is the re-labelling issue #111 names.
2026-10-17 — the typology catalogue decided, and the strata follow it (#111)¶
- Range:
5da851b..HEAD - Pages: none reported stale. The round touched
testing/evaluation/typology_quality.pyand 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. - The decision, taken and applied. Three forms with zero instances retired (
Ofício / Memorando,Ata de Reunião,Dossiê Funcional), three forms the collection has added with names chosen for their discriminative power (Livro de Sepultamento,Requerimento à Câmara Municipal,Publicação Impressa), and three definitions corrected to say what the curator's own labels say the drawers hold. Measured: agreement 0.775 → 0.865 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). - Reversibility. The typology catalogue has no revision ledger, so the before-state of all six
touched rows — names, ids and the full previous
context_description— is recorded in the issue comment that states the decision. That is what makes the change undoable. - Verified.
--pickruns against the new catalogue and proposes 8/8, 3/3 and 1/1 for the three new strata;count_pendingis 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. - Declared, not closed. 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
PHOTO_STRATUMmisses; and the revised gabarito needs a human signature, which the bench'sstatusgate exists to require and which is not mine to give.
2026-10-17 — a bench for the NER extractor (#113)¶
- Range:
5da851b..a76dc63 - Pages: none reported stale. The round touched
testing/evaluation/and its tests; no guide declares a bench as a source, and the set's shape is versioned intesting/evaluation/README.md, which is not a page of the site. - What the round built.
testing/evaluation/ner_quality.py, 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. - Three places the Definition of Done could not be met as written, all recorded in the issue:
there are no spans anywhere (
EntityExtractionEngine.extractreturns a DTO withnameandentity_type, and the link table has no offsets), so the boundary breakdown is a token-containment measure that counts the defect without locating it;input_textis deliberately not in the file, since the sheet reads the text through the worker's own composition; and a row is read when it carries aconfidenceor anote, so an untouched proposal is counted apart rather than scored as "no entity". - What it found in the collection, measured directly. 1,650 documents carry
worker_ner_v2 = "DONE", 41 carryworker_ner_v1 = "DONE", 3,142 carry no NER stamp at all, and not one carries a producer fingerprint: the NER output is unattributable in full, which is the concrete case behind #135. - Not verified, and said so. No human-corrected set exists, so there is no measurement of the
extractor yet — only the plumbing proof (a set whose
expectedis 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.
2026-10-18 — the hybrid ranking, and the numbers the issue was missing (#38)¶
- Range:
a76dc63..8b92fef - Pages:
guides/curate.md— updated: 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.guides/install.md— no-change:lib/screens.tsmoved, and what moved is the one-line description of an existing screen; no installation fact, screen count or label changed. - The issue's premise was measurably wrong, and that was the first finding.
#38quoted "MRR fell 0.019 while Hit@10 rose to 0.625" as evidence that the semantic ranking was weak against the lexical one. Both numbers come from9a075bc(2026-10-04) and are the before/after of the excerpt catalogue on the semantic arm (MRR 0.358 → 0.339,Hit@10 0.562 → 0.625), measured on 3,608 descriptions. No lexical-versus-semantic comparison existed anywhere in the repository, the bench the issue named (retrieval_quality.py) has no lexical arm and never had one, and the page that repeated the number —guides/curate.md— cited the improved figure as proof of weakness. - What the round built.
mode=hybrid: the full-text and semantic rankings built independently and fused byreciprocal_rank_fusion(domains/archive/domain/search.py,RRF_K = 60), over the topRRF_DEPTH = 100of each arm.totalis the union of the full-text matches with the semantic window and not every embedded document; the page is the fused window first and the full-text order after it;rankis 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. OneSearchModealias replaces theLiteralwritten three times, and the full-text mode stays the default, so a lexical request still never loads the model. - The measurement, per stratum, through the product path (
retrieval_modes.py, 16 labelled queries, 4,831 descriptions): sentence — lexical0.438 / 0.258, semantic0.562 / 0.293, hybrid0.625 / 0.296(Hit@10 / MRR); term — lexical0.875 / 0.703, semantic0.562 / 0.338, hybrid0.938 / 0.679. 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. - Two properties the arithmetic had to be corrected for, both measured. RRF is not a dominance
order — a first place plus a third beats a second place twice by 8·10⁻⁶ (
1/61 + 1/63against2/62), 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 stratumMRR 0.722, the shipped window of 100 gives0.679. - Latency is the full-text one plus about 7% (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 pre-existing: 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.
- What the round looked at. Two screens were rendered, not inferred: the headless shell over
CDP against the dev server and the scratch database (
scrinalia_render, 4,842 descriptions), with a session row inserted for the round and deleted after..render/hybrid.pngshows the hybrid mode selected, the note on screen and the results ofmatadouro— 139 descriptions, the facets filled, and a description without the term in its title ranked above the ones that carry it, which is the fusion visible on the page;.render/semantic.pngshows the semantic note and the same query answering 4,842 descriptions, the whole embedded collection, which is the count the fusion exists to stop reporting. - Not verified, and said so. The public surface's
mode=hybridis covered by the contract and by the repository tests, and was not rendered: no screen was opened against/api/v1/public. 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.
2026-10-18 — the search that filtered after joining, and the page that under-declared its sources (#140)¶
- Range:
8b92fef..c884152 - Pages:
guides/operate.md— updated: its last known-limit bullet said that ranking by semantics alone was weaker than the lexical one and that combining the two rankings is open work. Both halves had stopped being true in #139, which shipped the fused mode, and the page had never declared the search code among itssources:, 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 (domain/search.py,repository/document_repo.py,services/document_service.py, and the two retrieval benches) are declared — which is why this round was reported at all. - The defect the round fixed, measured before it was touched. A selective term (
enchente, 12 matches) took 18.0 s and a broad one (curitiba, 3,286) 6.6 s; two facet queries were 98% of that. The term reached the facets as anORof correlatedEXISTS, so it was evaluated once per link row — 64,504 entity links to find 12 descriptions — and theORkept the planner away from thesearch_vectorGIN index. The hardware was ruled out by measurement: 78 MB table, 128 MBshared_buffers, 99.98% cache hit ratio,shared hit=598,994and zero reads in the plan, no container limits,VACUUM (ANALYZE)worth 72 ms of 9,020. The commit before the fusion took 17,974 ms, so nothing here was the fusion's doing. - What the fix changed. 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
EXISTSper 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 anORof them (2.1 s → 0.31 s on its own). - The measurement, and the one regression. Eighteen query/mode/filter cases on the live collection,
results identical — same ids, order, ranks to ten decimals,
totaland 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. - What was looked at, and what was not. The screens were not 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.
- A guard, and proof that it bites.
testing/integration/archive/repository/test_search_predicate_shape.pypins 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.
2026-10-18 — the judge that wrote a failure as a verdict, and the preset that could not run (#122)¶
- Range:
c884152..5bfb1f8 - Pages:
guides/install.md— updated: the resource table said the presets namegranite4.1:3bandgemma4:e4b, 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.guides/install.pt.md— updated: the same two places, same structure.guides/operate.md— updated: theconflict-judgebullet 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--presetexample no longer lists the retired name.guides/operate.pt.md— updated: the same two places.- The measurement the retirement rests on, taken in this round. Ollama 0.24.0 on this machine:
granite4.1:3banswers{"error":"llama runner process has terminated: signal arrived during cgo execution"}to a one-word prompt, whilegemma4:e4bandgemma3:4banswerokon the same server. The model is installed, 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. - What the fix changed, and the gate that proves it. The engine raises
EngineExecutionErrorinstead of returningwinner="TAG"with confidence0.0; the worker leaves nollm_decisionand the pair stays pending; the bench'sFAILURE_REASON_PREFIXmatching is deleted. The pair of tests istesting/unit/archive/engines/test_ollama_tag_entity_conflict.py— 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,count_judged == 0, and the pair is asked again on the next run. - What was rendered. The panel's worker screen, with a session row inserted for the round and
deleted after it:
.render/workers-122.pngshows row 5 (conflict-judge) resolving toollama_judge · gemma_4b_local — gemma4:e4b, and.render/workers-122-stale-open.pngshows the same row after agranite_localrow was written directly intoarchive_worker_settings(deleted after the shot): the row readspadrão alterado, resolves togemma_4b_local, and the note at the bottom carries the reason. The retired name appears in no preset select. - Not verified, and said so. The bench was not 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
get_engine,describe_configand 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:check_titlestill swallows a transport error intois_suspect=Falsewith confidence0.0, 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.
2026-10-18 — the engine of an LLM_CHECK rule stops being free text (#124)¶
- Range:
5bfb1f8..d18c3f0 - Pages:
guides/curate.md— updated: the Regras section gained what theLLM_CHECKrule's two new selects are fed by (GET /api/v1/quality/cleaning-rules/engines, the title-quality axis's catalogue), that the pair is checked when the rule is written and refused as a422, that an empty engine still means the default, and what a rule written before that door does now.guides/curate.pt.md— updated: the same paragraph, same structure.guides/operate.md— updated: thequality-validatorrow of the queue table now says its producer identity is the "no model" one also when the rule's engine cannot be built, because that is the run the validator actually makes.guides/operate.pt.md— updated: the same row.- The defect, measured before it was touched. The screen's only hint was the placeholder
ex.: ollama, andollamais not a registered engine — the axis holdsollama_title_checkalone. Nothing validated the pair on the way in (CleaningService.create_cleaning_rulechecked the regex and nothing else), and the registry'sValueErrorescapedworker_quality_validator.execute, so the run was recordedFAILEDand the validator validated nothing while the rule stayed active. - What the change is, in three pieces. The catalogue is one definition
(
describe_axis_engines, shared with the operations panel) behind a new read route; the write validates the pair through the registry's owndescribe_configand raisesInvalidLLMCheckEngineError(422) whatever the rule kind is; and the two readers of a rule saved before that door —executeandproducer_identity— ask one question (unbuildable_rule_reason) and degrade to the deterministic stage and the "no model" identity. - What was rendered.
.render/cleaning-rules-124.png: the Nova regra form withTipo= consulta o modelo,Engine= padrão (ollama_title_check) andPreset= sem preset (o padrão do motor); the preset list read out of the DOM wasgemma_4b_local — gemma4:e4b, 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:[{"name":"ollama_title_check","presets":[{"name":"gemma_4b_local","config":{"model":"gemma4:e4b",...}}]}]. - Not verified, and said so. The refusal was exercised through the real route and the real
service by the integration test (a
POSTnamingollamaanswers422withInvalidLLMCheckEngineError), but not by clicking Salvar e ativar 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.
2026-10-18 — the legacy stamps stop reading as a producer (#137)¶
- Range:
d18c3f0..e625d49 - Pages:
guides/operate.md— updated: What each queue means now opens the stamp into the three things a slot can name (with a producer, without a known one, without a record) instead of oneprocessednumber; the paragraph on theERRORmark says the legacy value isPRODUCER_UNKNOWNnow and that the queue is untouched either way; the/sistema/workersrow of the panel table says what the card shows.guides/operate.pt.md— updated: the same three places.guides/data-model.md— updated: the stamp vocabulary is two types with different writers (a status raises in an identity slot), the five identity keys andPRODUCER_UNKNOWN, 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).guides/data-model.pt.md— updated: the same paragraph.- What was rendered.
.render/workers-137.png: 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, reads0 com produtor · 1.650 sem produtor conhecido · 3.181 sem registrounderFila (descrições), against4.813pending — the three numbers the issue asked to be readable, on the collection where the single number used to say 1.691. - The migration was run, not only written. On the reference collection,
f647dd7e6ad2 -> a7c1e5b9d3f2rewrote 3,511 document slots and 921 tag slots (worker_ner_v21,650,worker_quality_validator_v11,650,worker_macro_category_v1921,worker_typology_classifier_v2129, and the retiredworker_ner_v1andworker_typology_classifier_v141 each), with 0 fingerprints before and after and the pending counts unchanged. The downgrade was run too: the round trip returned 1,691 values toDONEand back toPRODUCER_UNKNOWN.alembic checkreports no drift. - Not verified, and said so. No worker was run after the migration, so no document was seen
being healed from
PRODUCER_UNKNOWNto a real fingerprint: that the next plain run re-reads those units is the predicate inidentity_stamp_conditions, 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 ofmacro-categoryis covered by the repository test and was not looked at on a screen.
2026-10-22 — the thumbnail failure mark is the plain ERROR (#149)¶
- Range:
0c09f99..d1494f1 - Pages:
guides/operate.md— updated: the failure count now namesERRORas the value and thethumbnail_failedkey as the thumbnail's own ledger, instead of suggesting a second failure spelling.guides/operate.pt.md— updated: the same paragraph, same structure.guides/data-model.md— updated: thethumbnail_failedrow namesERRORas the value, so the table no longer leaves the reader to infer it.guides/data-model.pt.md— updated: the same row.- Measured, not assumed. On the reference collection (
memoriacuritibana) the key has 0 rows; the migration was run up and down on a scratch database with a seededTruerow and returned it toTrueon the downgrade. The thumbnail queue and itsfailedcount read the key's presence, not the value, so the queue is unchanged. - Not verified, and said so. No screen changed, so no render was made; the panel's
failednumber 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.
2026-10-23 — the spaCy pipeline leaves the lock for a build step¶
- Range:
d1494f1..8af438e - Pages:
guides/install.md— updated: step 3 gains the install command (uv pip install --python .venv/bin/python --require-hashes -r models/requirements.txt) and the paragraph saying why the pipeline is not in the lock and thatuv syncprunes it;models/requirements.txtjoins the page'ssources:.guides/install.pt.md— updated: the same step and paragraph.guides/curate.md— no-change: the report named it forpackages/api-contract/openapi.json, and that file's whole diff in the range isinfo.versionfrom1.2.0to1.3.0(5a41a2d). No route, schema or screen moved, so the page still holds.- Measured, not assumed.
uv lockresolved 214 packages withoutpt-core-news-lg;uv sync --lockeduninstalled the package; the install command put it back in 6.06 s against a warm cache, andimportlib.metadata.version("pt-core-news-lg")answered3.8.0. The requirements line carries the sha256 the lock used to hold. - Not verified, and said so. The image was not built here: the Dockerfile step is that same
command, run after
uv syncin the same stage. And no Dependabot run has happened since the change, so the update job it exists for is still unobserved.
2026-10-24 — the entity link that could not say who wrote it (#135)¶
- Range:
d1494f1..1ac157d - Pages:
guides/data-model.md— updated: thearchive_document_entitiesrow carriesoriginandcreated_at, and sayscreated_atis null for a link that predates the column.guides/data-model.pt.md— updated: the same row.guides/curate.md— updated: theAssuntostab names the origin of each link, theConfirmaraction and its consequence, and the fact that a confirmation which changes no names writes no revision.guides/curate.pt.md— updated: the same paragraph.- Measured, not assumed. On a copy of the reference collection (
memoriacuritibana, 64.504 links over 4.811 documents) the migration ran up and down andalembic checkreported no drift; the backfill left exactly 64.504 rows asUNKNOWNand none asAI. The screen was rendered against that copy migrated to head:confirmada,IAandnão rastreadaappear on the same list, and clickingConfirmaron anUNKNOWNrow flipped it toHUMANboth in the database and on screen. - Not verified, and said so. No NER worker was run after the change: the
IArow in the render was written throughEntityRepository.bulk_link_entities— the worker's own write path — and not by a full extraction. And a confirmation that changes no names writes no revision, so thechanged_byof that act is not stored; the link's origin is the record, which the guide states.
2026-10-24 — the ban that matched every name (#160)¶
- Range:
1ac157d..9923231 - Pages:
guides/data-model.md— updated:domain_stopwordscarriesmatch_mode, anddomain_ner_exclusionsmatches the exact spelling; the table now says what the worker reads.guides/data-model.pt.md— updated: the same two rows.guides/curate.md— updated: the Stopwords tab gains theCasamentobullet — the PATCH, the two modes and why an exclusion is exact.guides/curate.pt.md— updated: the same bullet.- Measured, not assumed. On the reference collection the migration ran up, down and up again and
alembic checkreported no drift; the data decision set 9 stopwords toEXACTand removed the exactcâmara municipal de curitiba. The live measurement on the signed #138 set went from P 0.909 / R 0.920 / F1 0.915 with 13 blocked gold terms to P 0.917 / R 0.956 / F1 0.936 with none; ORG recall 0.568 → 0.611, boundary 27 → 12, spurious 109 → 115. - Not verified, and said so. No NER worker ran over the whole collection after the change: the
number is the bench's
livemode over the 122 labelled documents, not a re-extraction of the 4,813. The screen was rendered against a copy of the collection, where flippingalamedatoTOKENchanged both the select and the row in the database; the copy was then discarded.
2026-10-24 — the typology refusals have a screen (#129)¶
- Range:
9923231..8e3a903 - Pages:
guides/curate.md— updated: the Tipologias section gains the refusal queue,GET /api/v1/typologies/refusals, the dossier'sPATCHwrite, 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.guides/curate.pt.md— updated: the same paragraph and table row.guides/data-model.md— no-change: the report named it formodels/governance.py; the page namesllm_decisionbut not its length, and the queue paragraph (TYPOLOGY_LOW_CONFIDENCEand the score it saw) is unchanged.guides/operate.md— no-change: the report named it forrouter.tsx; the diff is the typology route'soffsetsearch, 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.- Measured, not assumed. The migration widened
llm_decisionto 100;alembic upgrade headapplied it on the dev database (scrinalia_zeroed) andalembic checkreports 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 0.5 ms for the page with or without it; 5,000 rows cost 15.8 ms / 3.9 ms (page/count) without and 18.7 / 4.8 with; 50,000 rows cost 85.6 / 15.0 without and 78.3 / 15.6 with. The only gain was 8.5% on the page at 50k, a queue this collection does not have. - Looked at, not assumed. 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
Planta Arquitetônicaand clickingClassificarremoved the row, changed the count from 3 to 2, wrotetypology_id=1andreview_status=HUMAN_APPROVEDon16124and one revision. The seeded rows, the revision and the temporary account were removed afterwards, and the document returned toNEEDS_REVIEWwith no typology. The empty state was rendered too, and says what empty means. - Not verified, and said so. 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.
2026-10-24 — the declared worker options are validated, published and recorded (#120)¶
- Range:
8e3a903..dbb00c8 - Pages:
guides/operate.md— updated: "What a worker accepts" (the declared options, the two column options and theAI_TEXT_COLUMNSthey are checked against, the run-onlylimit), the--engine-optionflag and its row, the two doors, the five refusals, the run ledger'soptionsand the stale row that is ignored with a note.guides/operate.pt.md— updated: the same sections.guides/curate.md— updated: the run panel is rendered fromdeclared_optionswith the pass-through block as the only free-form one, the settings form no longer has a JSON box and hideslimit, and the ledger row shows the effective options.guides/curate.pt.md— updated: the same three passages.guides/data-model.md— updated:archive_worker_runs.options, and the note that the ledger carries the resolved engine configuration and the resolved worker options.guides/data-model.pt.md— updated: the same row and note.guides/install.md— no-change: the report named it forpyproject.toml, and the only movement there is the release's version bump.- Measured, not assumed.
uv run pytestanswers 2028 passed, 1 skipped (the 34 new unit tests oftest_worker_options.py, the 3 of the pin intest_option_columns_pin.py, and the new cases in the runner and service suites);ruff check/ruff format --checkandbasedpyrightanswer zero.alembic upgrade headappliedefe037349552on the dev database andalembic checkreports no new upgrade operations.bun run contractregenerated the document and the client, andtsc, ESLint and the Vite build are green. - The pair was measured, not asserted. The typology benchmark and the write path now refuse the
same spelling through the same helper:
unknown_ai_columns(["original_title", "admin_bio_historia"])answers["admin_bio_historia"]for both, and the API's sentence names the five known columns. The measured default ofcolumns_to_extract, resolved by the worker itself, isoriginal_title, admin_bio_history, provenance, scope_content. - Looked at, not assumed. The three screens were rendered on the dev database through a temporary
admin account: the settings card of
typologyshows five column checkboxes, the confidence field and theforceflag, no JSON box and nolimit; the run panel ofnershows the same fields pluslimit, and its opened Repasses ao engine (avançado) block listsdevice, disable, modelas what the axis passes; the ledger shows a seeded run withcolumns_to_classify=original_title,scope_content · confidence_threshold=0.4and a row without the column reading "opções não registradas nesta execução". The seeded rows and the account were removed afterwards. - Not verified, and said so. No worker was run after the change: the recorded-options path is
proved by the integration test with a fake worker (the record of
nercarries the resolved default columns and not theNoneof 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--engine-optioncoercion is covered by unit tests on argument parsing, not by a run that loaded a model with an overriddendevice.
2026-10-24 — the SPA's request bodies are derived too (no page changed)¶
- Range:
dbb00c8..77bca6b - Pages: none, and that is the verdict this entry records. The change lives inside
apps/curator/src/api/andtesting/unit/curator/, and no page declares either path among itssources:—uv run python docs/_hooks/freshness.pyanswers "every page is fresh" and names none. What changed is a rule file,.dsh/skills/curator-ui/SKILL.md: a request body is derived from the model the route declares, never typed out, with the measured asymmetry beside it. - Why the round exists: #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 required field and silently ignores an unknown
one. Sizing it against the checkout found fifteen functions typing a body by hand, not the
thirteen the issue counted: the issue already subcounted (fourteen at
32d19f1, the commit nearest its date) andsetStopwordMatcharrived afterwards in9923231. - What the round measured. The thirteen request models do not share one policy: ten keep
pydantic's default (
extra="ignore") and three declareextra="forbid"—SubjectExclusionRequest,SubjectExclusionRemovalRequest,ConflictPreviewRequest. Renaming an optional field is silent in ten of the thirteen:EntityMergePreviewRequestwithnew_name→new_namvalidates and the merge simply does not rename, whileMergePreviewRequestfails only because its cross-field validator then finds neither source of truth — an accident, not a policy. The API-side question is #177. - Three things the issue did not know.
SubjectExclusionRequest.sourcehas aHUMANdefault, so the client's "required by the contract, and deliberately so" was false — the client now requires it by a derivedOmit/Required<Pick<>>, and the comment says why.NerExclusionRequestis shared by thePOSTand theDELETE, whose handler reads onlydata.words, the exact smellSubjectExclusionRemovalRequestalready documents — filed as #176. AndpreviewSubjectExclusionshad no caller since it was born in32d19f1: measured, the only unused export among 130, so the client function and its two response types are gone and the intent lives in #175. - Verified, and what was not. 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.
tsc, ESLint, the Vite build,ruff check/format --checkandbasedpyrightanswer zero, anduv run pytest testing/unitanswers 1044 passed, 1 skipped. Not looked at in a browser, and nothing to look at: the emitted bundle is byte-identical to the pre-change one (dist/assets/index-DIQDkiK4.js, sha25621ca64dc0b02571364431c9c52300079d69fc236c8d03b54a06ddc015dd0b75c), because a type-level change erases at compile time — so no screen's pixels can differ, and no rendering would show the change.
2026-10-24 — the NER removal declares only what it reads (#176)¶
- Range:
77bca6b..2e0e9b9 - Pages:
guides/curate.md— no-change: the page was reported stale through the contract and the taxonomy controller, and the paragraph it owns (Exclusões de NER) names the reversal as a plainDELETEthat re-opens the term, never as a body. The removal's model is not described there, and the screen's call sends{words: [word]}either way, so the text still holds.- Why the round exists: the
DELETEon/entities/ner-exclusionstook the ban'sNerExclusionRequestand read onlydata.words, so areasonanswered 200 and vanished; the client had narrowed it back withPick<NerExclusionRequest, "words">. #176 gave the removal its own model,SubjectExclusionRemovalRequest's twin, and the narrowing went with it. - What the round measured. The contract diff is the new schema and the
$refof the one operation, nothing else:bun run contractemitsNerExclusionRemovalRequestwithwordsandminItems: 1, and the generated client's signature changes from the narrowing to the model. The behaviour is pinned bytest_remove_ner_exclusions_refuses_the_reason_the_route_ignores(areasonis a 400, and the service is not called) and by the table intesting/unit/curator/test_api_body_shapes.py, which now lists three narrowings instead of four.uv run pytest testing/integration/api/controller/test_taxonomy_controller.pyanswers 63 passed against the test database;testing/unit/api,testing/unit/curatorandtesting/unit/docsanswer 147 passed;ruff check,ruff format --checkandbasedpyrightanswer zero, andtsc/ESLint are green. - Not verified, and said so. 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.
2026-10-24 — the refusal comes before the write (#175)¶
- Range:
2e0e9b9..83f7d39 - Pages:
guides/curate.md— updated: the Não é assunto 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.guides/curate.pt.md— updated: the same paragraph, same structure.- Why the round exists: #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
blockedfrom the write's answer instead, so the refusal arrived after the click. The route also declared the write'sSubjectExclusionRequestand read onlydata.words— #176's smell in a second place, which this round fixed withSubjectExclusionPreviewRequestrather than adding a narrowing to the client. - What the round measured. The contract diff is the new schema and the one operation's
$ref. The behaviour is pinned bytest_the_subject_exclusion_preview_declares_only_the_terms— the preview refuses areason/sourcebody with 400 — and by the existing dry-run test, which sends{"words": [...]}and still reads the same plan.pytestover the tag catalogue and the body-shape gate: 20 passed;tsc, ESLint,ruff check/format --checkandbasedpyrightanswer zero. - What was rendered.
.render/subject-exclusions-preview.pngand.render/subject-exclusions-blocked.png: the built SPA served by the API (create_app()mountsdist) againstscrinalia_zeroed, with a session row inserted for the round and deleted after it, driven over CDP. Withcapejo, vista aereain the box the screen reads, before any click,1 termo(s) já estão numa categoria de assunto e não serão registrados: capejo (Mobilidade e Transporte)beside1 termo(s) serão registrados e banidos de voltar a virar tag., and the button stays enabled; withcapejoalone only the refusal line renders and the button is disabled (CDP reporteddisabled=trueand the pixels show it greyed). - Not verified, and said so. 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
scrinalia_zeroedonly (101 descriptions, 139 exclusions, 83 tags in drawers), not on the large collection.
2026-10-24 — the stamp breakdown says which ledger it counts (#150)¶
- Range:
83f7d39..55fda25 - Pages:
guides/operate.md— updated: What each queue means gains the paragraph that separates the four numbers frompending— 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.guides/operate.pt.md— updated: the same paragraph.guides/curate.md— updated: 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.guides/curate.pt.md— updated: the same sentence.- Why the round exists: #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
stamp_breakdownand the card rendered it underFila; the number was right and the scope was not. - What the round measured. The difference is not arithmetic in the abstract: on the collection the
render used,
select review_status, count(*)gives 18HUMAN_APPROVEDand no document without text in the extracted columns, so4.831 − 18 = 4.813isai_writable_documents()doing exactly what it promises. The contract diff is the description of one field (stamp_breakdown), with the component untouched (the field node carries the description, not theWorkerStampBreakdownDTO). - What was rendered.
.render/workers-150.png: the built SPA served by the API against a clone of the large collection (scrinalia_150,CREATE DATABASE ... TEMPLATE memoriacuritibana, thenalembic upgrade head— two additive migrations — dropped after the round), with a session row inserted and deleted for it, driven over CDP. The NER card, expanded, reads FILA (DESCRIÇÕES) 4.813 with "o predicado da fila: o que a próxima execução leria. Não é o ledger do worker." beside CARIMBOS (DESCRIÇÕES) 4.831 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 (sm:grid-cols-2 xl:grid-cols-4). - Not verified, and said so. 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'
Processadosfact was not opened in pixels — the NER card is the stamp case the issue is about.
2026-10-24 — the clustering's columns are a read (#174)¶
- Range:
c71f3aa..5900c48 - Pages:
guides/curate.md— updated: Descobrir Categorias gains the picker — the read route, whyfinal_titleis in the corpus and out of the default, and the three refusals (unknown column, empty list, list withsource_type=tags).guides/curate.pt.md— updated: the same paragraph.guides/operate.md— no-change: the report named it throughdocument_repo.py, where the four-column fallback becameCLUSTERING_TEXT_COLUMNS. The value is the same four columns and the page never described them; what the clustering reads is the curation page's subject now.- Why the round exists: #174. The route accepted
columns_to_extractsince it existed and no screen sent it; #93 narrowed the client toPick<SuggestMacroRequest, "source_type">rather than advertise a knob nothing set. The other half was a second definition: the repository fell back to a literal list of four whileAI_TEXT_COLUMNShas five, and an unknown name reachedgetattr(ArchiveDocument, column)for a 500. - What the round measured. The contract grows by one operation and one schema
(
ClusteringColumnsResponse, 45 lines inschema.d.ts).CLUSTERING_TEXT_COLUMNSkeeps the same four values, so no behaviour changed for a caller that omits the field; the unit test pins that the difference fromAI_TEXT_COLUMNSis exactlyfinal_title. The integration tests answer 41 passed over the tag catalogue, the text-quality domain and the body-shape gate — including the 400 that namesoriginal_titel, the 400 forsource_type=tags, and the 400 for[]. - What was rendered.
.render/discover-columns.png,.render/discover-off.pngand.render/discover-send.png: the built SPA served by the API againstscrinalia_zeroed, 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(padrão)andfinal_titleunchecked; every box unchecked disables Propor Categorias and printsEscolha ao menos uma coluna.; and with the five checked the request the screen sent was, read off the network event,{"source_type":"documents","columns_to_extract":["original_title","admin_bio_history","provenance","scope_content","final_title"]}. - Not verified, and said so. The clustering itself was not run to completion: 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
fetch_documents_for_clustering(columns_to_extract=...)receives them.
2026-10-24 — a request body refuses what it does not know (#177)¶
- Range:
5900c48..4a8b78f - Pages:
guides/operate.md— no-change: the report named it throughsystem_schema.py, whose two request models now inheritRequestModel. The page documents the options contract — a key the worker does not declare is refused insideoptions— and this change is about an unknown key at the top level of a body, which the page never said was accepted.- Why the round exists: #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
extra="forbid"and 31 kept pydantic's default, so the same stale client answered 400 on one route and 200 on the next. - What the round measured. The generated contract is unchanged:
additionalPropertieswas already absent for the 22 strict models, so Litestar never expressed the difference, and addingextra="forbid"to the other 31 emits nothing either — measured here by regeneratingopenapi.jsonandschema.d.tsand getting a 0-line diff. The gate istesting/unit/api/test_request_models.py: it resolves the bodies from the built application (55 of them, exactly the names the contract points at from arequestBody), and fails both on a body model that does not forbid and on a walk that stops finding them. The full suite answers 2041 passed, 1 skipped;basedpyright,ruff checkandruff format --checkanswer zero. - What the policy changed in behaviour, and the two tests that said otherwise. Two calls used to
be accepted and silently ignored: a
rolein the setup body (test_the_client_cannot_choose_the_roleasserted 201 and ADMIN) and arequested_byin the run trigger. Both are 400 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: achanged_byin the materialisation body is refused, because the author is the session's. - A finding, not fixed here.
HierarchyMaterialisationRequestexists twice: inapi/schemas/hierarchy_requests.py(the body:note,limit) and indomains/archive/schemas/hierarchy_schema.py(the command: the same pluschanged_by). 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. - Not verified, and said so. 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.
2026-10-25 — the materialisation command has its own name (#184)¶
- Range:
4a8b78f..74a3a5c - Pages: none changed by this round, and the two the report named are the merge's trace:
guides/curate.md— no-change: the report named it througha24154e, the merge of the #174 branch, whose page text was written and triaged in that round (c71f3aa..5900c48). No declared source moved after it — the change here is a class name in the domain schemas, which the page never prints.guides/curate.pt.md— no-change: the same merge, and the translation was updated with the English page in the #174 round.- Why the round exists: #184, the finding #177 recorded. Two models shared the name
HierarchyMaterialisationRequest— the HTTP body (no author, the one the contract$refs) and the service command (changed_by); the controller imported both, the second asMaterialisationCommand, and the docstrings had drifted by a word. - What the round measured. The rename moved 22 references in 4 files — the domain schema, the
package
__init__, the service and the controller (whose alias disappeared) — plus 12 constructions in the service's integration test, and zero lines of contract: regeneratingopenapi.jsonandschema.d.tsleaves both byte-identical, because the command is not bound to a route. The gate istesting/unit/quality/test_model_names.py: of the 276 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 tohierarchy_requests.pymade it fail; the file was restored byte-identical). Full suite 2044 passed, 1 skipped;basedpyright,ruff check/format --checkandtsc/ESLint green. - Not verified, and said so. 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.
2026-10-25 — the workers leave the API (ADR 0014)¶
- Range:
74a3a5c..27063f7 - Pages:
adr/0014-the-workers-leave-the-api.md— createdadr/0004-worker-execution-from-the-api.md— updated (superseded)adr/index.md— updatedguides/install.md/install.pt.md— updated: the runner and the API; the network and the credential; the GPU host; the composerunnerservice.guides/operate.md/operate.pt.md— updated: the runner; interrupted runs by lease; the queue in the ledger; the panel's runners and availability; the limits.guides/data-model.md/data-model.pt.md— updated: the request/runner/lease columns andarchive_worker_runners.README.md— updated: the runner, the image andbun run dev.SECURITY.md— updated: the runner's database reachability replaces the single-process bullet.guides/curate.md/curate.pt.md— no-change: 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.- Why the round exists: #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.
- What the round measured. Six commits: the ADR; the queue columns/table/indexes; the runner
credential; the claim/lease/run; the API only enqueueing; the panel. The migration
a842f9efad08, andalembic checkreports no drift. Full suite 2079 passed, 1 skipped;basedpyright,ruff,tsc, ESLint and the Vite build green. Rendered and looked at: the workers panel (an online runner with two capabilities,sem runneron the seven others,Enfileirar execução) and the run history (a live run enqueued by the panel, claimed and closed by a runner, showingrunner,sinalandlease). The run was #9,SUCCESS, claimed 0.46 s after the enqueue. - Not verified, and said so. 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
RUNNER_DATABASE_URL/DB_SSLMODEpath 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.
2026-10-25 — schedules and retries (ADR 0015)¶
- Range:
27063f7..692de01 - Pages:
adr/0015-schedules-and-retries.md— created: the agenda, the scheduler, the retry and the slot.adr/index.md— updated.guides/install.md/install.pt.md— updated: theDEFAULT_TIMEZONErow, the scheduler process, the compose service.guides/operate.md/operate.pt.md— updated: the "no scheduler" section replaced by "Scheduling and retries"; the agenda on/configuracoes/workersand/sistema/workers; the retry policy and the slot; the limits.guides/data-model.md/data-model.pt.md— updated:archive_worker_schedulesand theattempt/retry_of/not_beforecolumns.README.md— updated: the scheduler and the retry.AGENTS.md— updated: the scheduler command and the dev script (PATCH 1.2.2, 15,998 B)..dsh/skills/workers,.dsh/skills/data— updated: the scheduler/retry and the 34 settings.- Why the round exists: #43, milestone 1.6: the scheduling and the retry ADR 0014 deferred.
- What the round measured. 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
b7e4c2a9f1d0, andalembic checkreports no drift. Full suite 2124 passed, 1 skipped;basedpyright,ruff,tsc, ESLint and the Vite build green;mkdocs --strictgreen. Rendered and looked at: the agenda in/configuracoes/workers(the*/1 * * * *cron,America/Sao_Paulo, the enabled state and the next run), the agenda badge and fact in/sistema/workers(withsem runneron the seven workers the runner does not accept) and fourSCHEDULEruns in/sistema/execucoes, claimed byrender:9055ae26. - Not verified, and said so. 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.
2026-10-25 — a run can be stopped from the panel (#72)¶
- Range:
57c58fd..b84cad3 - Pages:
guides/operate.md/operate.pt.md— updated: the ledger columns and theCANCELLEDstatus, the new Cancelling a run section (the request, the two outcomes, the batch boundary, the CLI signals and the daemon'sINTERRUPTED), the route and screen tables, the retry note and the limits. The trigger list now namesSCHEDULE, which the #43 round left out.guides/data-model.md/data-model.pt.md— updated:cancel_requested_at,cancel_requested_by/_user_idand theCANCELLEDvalue onarchive_worker_runs.guides/curate.md/curate.pt.md— updated: the Execuções screen's one write, with the confirmation and the honest granularity..dsh/skills/workers— updated: the cancellation is a request,RunControl.checkpoint, the source gate, theBaseExceptionchoice and the CLI/daemon split.adr/0014— no-change: decision 9 fixed this vocabulary and named #72; the ADR records the decision, and this round is its implementation.- Why the round exists: #72, milestone 1.6 — the panel could start work it could not stop, and
the only lever was
docker compose stop. - What the round measured. Four code commits: the ledger (the enum value, the two columns with
their author link, the migration
d72c0a4b1e9f, the DTO and the repository); the runner (the oneRunControl.checkpoint, 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 2146 passed, 1 skipped;basedpyright,ruff,tsc, ESLint and the Vite build green;alembic upgrade headandalembic checkreport no drift;mkdocs --strictgreen. - What was rendered.
.render/72-runs.pngshows the three states together: theRUNNINGembeddingrow with "Cancelamento pedido em 25/10/2026, 14:05 por Cassio…", theCANCELLEDthumbnailrow, and theQUEUEDtypologyrow with Cancelar execução…..render/72-confirm.pngshows the inline confirmation and its sentence;.render/72-cancelled.pngshows thetypologyrow after the browser clicked Confirmar cancelamento — the POST ran through the real route and the row came back Cancelada. TheRUNNINGrow was claimed byrender:demowith a directUPDATE(no worker process), and the request was recorded through the API; the scratch session row and the three runs were deleted after the round. - Not verified, and said so. 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
SIGTERMwas delivered by the operating system to a process mid-batch. The daemon'sSIGTERM→ lease →INTERRUPTEDpath was not exercised.
2026-10-25 — the origin is a first-class object (#44)¶
- Range:
b84cad3..8e0c42f - Pages:
adr/0016-the-origin-is-a-first-class-object.md— created: the decision (definition in code + lifecycle row,origin_codeas provenance, theingestionstage in the same ledger, the manual entry asPENDING_AI, the deferred CSV and per-origin AI settings).adr/index.md,mkdocs.yml— updated: the new ADR is linked and in the nav.guides/operate.md/operate.pt.md— updated: ten workers withingestionfirst, the origin table and screen, the manual entry, the per-origin slot and agenda, the removedACERVO_SOURCE, the origin routes, theoriginfilter on the ledger and theingestionrow that links to the origin screen.guides/install.md/install.pt.md— updated: the origin is a row, not a setting;PUBLIC_SCRAPE_*is the fallback forpmc; the migration seedspmcandmanual; the upgrade path is/sistema/ingestao.guides/data-model.md/data-model.pt.md— updated:ingestion_origins,origin_codeon the queue/raw/staging/archive/run ledger,created_byonraw_data, and the provenance-not-key rule.guides/curate.md/curate.pt.md— updated: the/sistema/ingestaoscreen, 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.README.md— updated: the ten worker names, the ingestion screen and the origin agenda..env.example— updated:ACERVO_SOURCEremoved;PUBLIC_SCRAPE_*documented as the reference adapter's fallback..dsh/skills/ingestion— updated: the origin object, per-record schema, theingestionstage and the manual origin; the oldACERVO_SOURCErule removed..dsh/skills/workers— updated: theingestionspec and the per-origin slot..dsh/skills/data— updated: 33 settings fields, noACERVO_SOURCE, the origin is a row.- Why the round exists: #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.
- What the round measured. Full suite 2164 passed, 1 skipped;
basedpyright0 errors;ruff checkandruff format --checkgreen;tsc, ESLint and the Vite build green;alembic upgrade headapplied44a1b2c3d4e5andalembic checkreports no drift;mkdocs build --strictgreen. The generated contract was regenerated and matches the app. - What was rendered.
.render/44-ingestao.pngis the screen with the manual form and both origin cards;.render/44-ingestao-config.pngis the PMC configuration panel (URLs and delay);.render/44-ingestao-agenda.pngis its agenda (cron, IANA zone, save/remove);.render/44-ingestao-submit.pngis a real 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. - Not verified, and said so. No runner was online, so the queued transfer was not executed by a
runner in the browser; the archive
PENDING_AIand the NER queue are covered by the integration test (test_manual_ingestion_stages_the_record_and_queues_the_transfer), which runs the transfer directly. A real scraper execution of theingestionworker 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.
2026-10-25 — the ingestion panel stops reporting a total (#49)¶
- Range:
8e0c42f..e1362e1 - Pages:
guides/operate.md/operate.pt.md— updated: theingestionrow in/sistema/workershas no aggregate pending/processed/failed counters — those are per origin on/sistema/ingestao; the health card readsingestion_origins(active/total and each origin's label, with a link), and the process card labelsPUBLIC_SCRAPE_*as the environment fallback it is. The health route and screen rows name the new summary..dsh/skills/workers— updated: theingestionspec has no aggregate counters; the row is a pipeline index..dsh/skills/observability— updated: the health probe reads the origin table, not the env flag, and the queue stays on the ingestion screen.- Why the round exists: #49 was written before
/sistema/ingestao; #44 delivered the intent, but left a row whoseprocessed/failedwere 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. - What the round measured. Full suite 2167 passed, 1 skipped;
basedpyright0 errors;ruff checkandruff format --checkgreen;tsc, ESLint and the Vite build green; the API contract was regenerated and matches;mkdocs build --strictgreen. Rendered with the dev database and a temporary admin:.render/49-health.pngshows "2 de 2 origens ativas", both labels and the Abrir ingestão link, and the process card says "fallback de coleta no .env presente";.render/49-workers.pngshows theingestionrow with Abrir ingestão, no aggregate counters. - Not verified, and said so. The Abrir ingestão link was rendered and typechecked, but was not
followed in the browser this round;
/sistema/ingestaoitself 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.
2026-10-25 — the description thumbnail reaches the screen (#102)¶
- Range:
89b2ec6..ed52347 - Pages:
adr/0017-the-thumbnail-reaches-the-browser-through-the-api.md— created: the delivery decision (an authenticated proxy route, athumbnail_urlderived 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.adr/index.md,mkdocs.yml— updated: the new ADR is linked and in the nav.guides/curate.md/curate.pt.md— updated: the dossier's media card, the list's 56 px miniature, the three states ("sem imagem", "indisponível", the image), the route and itsthumbnail_url, and the public projection still carrying the raws3://reference (#46).guides/operate.md/operate.pt.md— updated: the worker writes the object; the API serves it on demand, so the endpoint must stay reachable from the API and allowGetObject.- Why the round exists: #102 — the
thumbnailworker had filled 97 of the 101 reference descriptions and no screen read the field; the browser cannot resolve ans3://URI, and the endpoint is the API's private one (.envsayshttp://localhost:8333, the compose file sayshttp://host.docker.internal:9000). 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. - What the round measured. Full suite 2187 passed, 1 skipped;
basedpyright0 errors;ruff checkandruff format --checkgreen;tsc, ESLint and the Vite build green; the API contract was regenerated and matches the app;mkdocs build --strictgreen. The 101 descriptions yield 97 with astorage_thumbnail_uriand 4 with neither a stored URI nor a source image; one object sampled in the local bucket is 384×249, 119,702 bytes, image/jpeg. - What was rendered.
.render/102-built-dossie-imagem.pngis the dossier with a real stored image;.render/102-built-dossie-sem-imagem.pngis description 2731, one of the four without a source image;.render/102-built-dossie-indisponivel.pngis description 1000, whose URI has no object in the bucket and therefore answers 404;.render/102-built-lista.pngis the collection list with miniatures and the same two placeholders. All four are the built SPA served by the API at:8000, rendered in the headless shell at 1280×1000, with a temporary session row that was removed afterwards. - Not verified, and said so. The bucket used for the render had 40 of the 97 objects (copied
from the old
memoria-curitibana-bronzevolume intomeu-bucket-teste); 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 belowsmwas not rendered; the evidence there is the classes. The public route is #46 and was not implemented, soPublicDocumentSummarystill publishes the raws3://URI. No migration.
2026-10-25 — a trial before committing the collection, and the gabarito it reads (#71)¶
- Range:
984b514..06bada0 - Pages:
adr/0018-trials-and-the-human-gabarito.md— new: a trial is a ledger row (trial,trial_result) 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 latestarchive_document_revisionsrow that changedtypology_id,curated_macro_category,origin=HUMAN, the conflict resolution — and neverHUMAN_APPROVEDalone; the sample is the verdict set plus a seeded ai-writable sample; the comparison produces the settings write. Index andmkdocs.ymlnav updated.guides/operate.md/operate.pt.md— updated: "Comparing configurations on a sample" (the trial, the two populations, the save/run flow), the two new routes and thetrialfilter in the operations table, theensaiobadge and the comparison card in the screens table, andtrial/trial_resultin the run ledger.guides/data-model.md/data-model.pt.md— updated: thearchive_worker_runsrow carriestrialandtrial_result(JSONB)..dsh/skills/workers— updated: the trial is a run kind, the metric lives in the package, the gabarito rule, and the counter options now come fromconfiguration.counter_options.guides/curate.md— no-change: the hook flags it because the SPA routes and the contract it declares moved, but the comparison card lives on/configuracoes/workers, the operator screen the operations guide owns, and it needsOPERATE; no curation workflow changed.guides/install.md— no-change: the runner daemon's flags and the deploy are unchanged; the trial is dispatched from a claimed row, not from a new installation step.- Why the round exists: #70 made a classified description re-classifiable, so a configuration can
be re-run; #71 answers the question before the run — which configuration is better on this
collection, and what does it cost? The decision the issue demanded first, which screen owns the
human verdict and how it relates to
HUMAN_APPROVED, 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 (domains/archive/evaluation/metrics.py), and the worker calls the samelink_decision. - What the round measured. Full suite 2197 passed, 1 skipped on the rebased branch (test database on 5434,
postgresql+psycopg2);basedpyright0 errors;ruff checkandruff format --checkgreen; the contract was regenerated withbun run contractand the SPA'stsc, ESLint and Vite build are green;mkdocs build --strictgreen;alembic upgrade headapplied71a2b4c6d8e0andalembic checkreports no drift. Rendered with the headless shell over CDP:.render/71-trial-table.pngis the comparison card of a finished trial (the side-by-side table, themelhor concordânciabadge and the disabled Rodar no acervo inteiro),.render/71-ledger-ensaio.pngis the ledger row with theensaiobadge and the link back to the settings, and.render/71-settings-trial.pngis the settings screen with the card in place. - Not verified, and said so. The trial was not run against a real model: the rendered result is
a scratch
archive_worker_runsrow (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 Rodar ensaio 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.
2026-10-25 — a CSV imported without storage (#191)¶
- Range:
6cc6349..6ff7c7d - Pages:
adr/0019-the-csv-import-is-parsed-in-the-browser.md— created: the file never leaves the browser, thecsvorigin 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.adr/index.md— updated: the 0019 row.guides/curate.md/curate.pt.md— updated: the Importação CSV card in the ingestion screen, the table row that now says a curator may type a record or import a CSV, and the per-row answer (accepted/duplicated/invalid, partial import,PENDING_AI).guides/operate.md/operate.pt.md— updated: 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 (csvhas no adapter configuration) and the permissions sentence that now names both curator writes.guides/data-model.md/data-model.pt.md— updated:raw_data.created_byis set by the form-based origins (manual,csv), andingestion_origins.configsays the csv origin has none. The Portugueseraw_datarow had lostorigin_codeand the two author columns; the round put them back in step with the English table.guides/install.md— no-change: the hook flagspackage.jsonandsources.py, but the change adds a contributor test script and an origin vocabulary, not a setting or an installation step; thecurator:testcommand is inCONTRIBUTING.md, where the local checks live..dsh/skills/ingestion/SKILL.md— updated: the CSV rules (no storage, the one catalogue and the limits, the strict row rules, thedescription_idsstaging window).CHANGELOG.md— updated: the Unreleased/Added entry.mkdocs.yml— updated: the ADR 0019 nav entry.- Why the round exists. #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.
- What the round measured. Full suite 2228 passed, 1 skipped (the test database on 5434,
postgresql+psycopg2; 5433 was held by another process);ruff checkandruff format --checkgreen;basedpyright0 errors; the contract was regenerated with the repository'sopenapi.json+codegenpath and the SPA'stsc, ESLint,bun test(20 pass) and Vite build are green;mkdocs build --strictgreen and the documentation coverage gate 29 passed;alembic upgrade headapplied191c5f0a7b2eandalembic checkreports 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:.render/191-preview.pngis the card with the detected headers, the preview table and the local duplicate warning;.render/191-result.pngis the per-row report — 2 accepted, 1 duplicated and 1 invalid (A data 'banana' não foi reconhecida.) — 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. - Not verified, and said so. The transfer was not run during the render — no runner was
attached — so the archive description reaching
PENDING_AIwas not observed in the browser; it is asserted by the integration test that runsworker_archive_transfer.executeover the imported rows. The Baixar modelo button was not clicked;buildTemplate/toCsvare covered bybun test, 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'sno-changeis a judgement from the diff, not from a page render.
2026-10-26 — the archive keeps the ISAD(G) record (#199)¶
- Range:
6ff7c7d..08b3ccb - Pages:
guides/data-model.md/data-model.pt.md— updated:staging_documentsgainsdate_expressionand the archive gains the full ISAD(G) text set plusparent_reference_code,date_expressionandoriginal_url; the paragraph explains why the spelling travels beside the parsed date and why the declared parent is a column now.guides/curate.md/curate.pt.md— updated: 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.guides/operate.md/operate.pt.md— no-change: 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.guides/install.md— no-change: no setting moved; the language catalogue is code, not a new environment variable.adr/— no-change: 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.- Why the round exists. #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.
- What the round measured. Full suite 2238 passed, 1 skipped (the test database on 5434,
postgresql+psycopg2; 5433 is held by another process);ruff checkandruff format --checkgreen;basedpyright0 errors; the contract regenerated with the repository'sopenapi.json+codegenpath, and the SPA'stsc, ESLint and Vite build green;mkdocs build --strictgreen and the documentation coverage gate 29 passed;alembic upgrade headappliede3084fbf75baandalembic checkreports no new operations. The backfill was measured on the dev database (scrinalia_zeroed): 101 archive rows against 100 staging rows, so the migration filled 100 descriptions and left 1 empty (no staging row), anddate_expressionis 0 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:.render/199-dossier-top.pngis 3.1 (level, typology, reference code, date, dimension and support, the parent named from the branch) and 3.2 with the source link;.render/199-dossier-mid.pngis 3.3 and 3.4, where the language select shows "Português Brasil (fora do catálogo)" — the scraped spelling, kept instead of erased;.render/199-dossier-bottom.pngis 3.5 to 3.7, the diffusion flag, the disabled Objeto digital 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. - Not verified, and said so. The transfer was not run 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 saved through
the dossier in the browser; the write path is the same
PATCHcovered 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 sameGET /api/v1/languageslist the route test pins.
2026-10-26 — the manual entry covers the whole record (#192)¶
- Range:
08b3ccb..33217f7 - Pages:
guides/operate.md/operate.pt.md— updated: the manual entry has a screen of its own at/sistema/ingestao/nova, 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.guides/curate.md/curate.pt.md— updated: the screens table gains the new row, and the ingestion section says the card opens the form instead of embedding it.guides/data-model.md/data-model.pt.md— no-change: the round writes the same columns the archive gained in #199; nothing new in the schema.guides/install.md— no-change: no setting moved, and the language catalogue is code, not a variable.adr/— no-change: this is the form catching up with a contract that already existed.- Why the round exists. #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).
- What the round measured. Full suite 2244 passed, 1 skipped against the test database on
5434;
ruff checkandruff format --checkgreen;basedpyright0 errors; the contract regenerated (31 fields inManualIngestionRequest,attachment_linkgone, the result carrying the parsed date); the SPA'stsc, ESLint and Vite build green;mkdocs build --strictgreen and the documentation coverage gate 29 passed, including the new screen. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and the API:.render/192-form-top.pngis the identity area open — title, level, parent, reference code, the text date with its placeholder and help, dimension and support — with the six other areas collapsed, the disabled Objeto digital control and the submit disabled while the title is empty;.render/192-form-area.pngis 3.4 expanded with the language select and the help under each control;.render/192-ingestion.pngis the ingestion screen with the Entrada manual card reduced to the explanation and the Nova descrição 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. - Not verified, and said so. Nothing was submitted 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
requires_parentwarning 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 bytest_csv_import.pyand by the equality pin.
2026-10-26 — one spelling per language (#203)¶
- Range:
e9f0062..8660c6f - Pages:
guides/data-model.md/data-model.pt.md— updated:language_nameholds 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;staging_documentskeeps the declared text.guides/curate.md/curate.pt.md— updated: the dossier's select reads the catalogued name (a variant is folded before it arrives), and the(fora do catálogo)marker is now only for a language the catalogue has never heard of.guides/operate.md/operate.pt.md— no-change: the manual form already sent a catalogue name; the fold is about values arriving from the origins and from the past.adr/— no-change: it is the catalogue #199 created, given the normalisation every closed catalogue in this codebase has.- Why the round exists. The render that closed #199 measured the collection:
Português Brasilin 78 descriptions andPortuguês/Brasilin 22, both shown as unknown because the dossier compares strings and the catalogue saysPortuguês. Keeping the source's text is the right default; two spellings of one language in a closed list is not. - What the round measured. Full suite 2270 passed, 1 skipped against the test database on
5434;
ruff checkandruff format --checkgreen;basedpyright0 errors;mkdocs build --strictgreen and the documentation coverage gate 29 passed;alembic upgrade headapplied6c46426b048aandalembic checkreports no new operations. On the dev database the migration folded 100 rows instaging_documentsand 100 inarchive_documents(the collection has 101 descriptions, one of them with no language), and the two spellings became one:Português 100,NULL 1. Rendered with the headless shell over CDP at 1280×1600 against the Vite dev server and the API:.render/203-language.pngis the dossier's 3.4 area with the Idioma select readingPortuguês— the same record that showedPortuguês Brasil (fora do catálogo)before the fold. The temporary render account and its sessions were deleted, and the browser profile and driver are gone. - Not verified, and said so. The fold was not exercised through a new scrape: the transfer
path is covered by the integration test that stages
Português/Brasiland asserts the archive'sPortuguêswhile 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.
2026-10-26 — the entry screen moves to the Acervo (#205)¶
- Range:
8660c6f..35d11cb - Pages:
guides/curate.md/curate.pt.md— updated: Nova descrição is/acervo/nova, the first entry of the Acervo menu, with a section of its own: the Digitar and Importar CSV tabs (the mode travels in?modo=csv), the destination said once, and the redirect from/sistema/ingestao/nova. The Ingestão section loses the two cards and says the curator reads the origins read-only.guides/operate.md/operate.pt.md— updated: the manual entry is the Digitar tab and the CSV is the Importar CSV tab, both at/acervo/nova;/sistema/ingestaois origins only, and itsCURATEcard now buys the status read, not a curator write.guides/install.md/install.pt.md— updated: after the migration/sistema/ingestaoconfigures the origins and/acervo/novais where a description is typed or imported.guides/data-model.md/data-model.pt.md— no-change: no table or column moved; the staging and transfer paths are the same.adr/— no-change: the API and the pipeline are untouched. This is where a screen lives, not an architecture decision.- Freshness. Before the round the hook named
guides/curate.md(the conflict tabs, #220) andguides/install.md(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. - Why the round exists. #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.
- What the round measured. Full suite 2270 passed, 1 skipped against the test database on
5434 (
postgresql+psycopg2; 5433 is held by another process and itstest_userrefused the password);ruff checkandruff formatgreen andbasedpyright0 errors through the commit hook;bun run --cwd apps/curator typecheck,lintandbuildgreen;bun test20 pass;testing/unit/curatorandtesting/unit/docs32 passed;mkdocs build --strictgreen; 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:.render/205-digitar.pngis/acervo/novawith the menu entry and the form;.render/205-csv.pngis?modo=csv;.render/205-csv-preview.pngis a sample CSV mapped header by header with one invalid row before any submit;.render/205-ingestao.pngis the origins page with no entry cards. The CDP probe switched tabs to/acervo/nova?modo=csv, kept a typed draft across the round trip, and landed/sistema/ingestao/novaon/acervo/nova. The temporary session row was deleted and the driver and profile removed. - Not verified, and said so. Nothing was submitted 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
permissions.tsand the API guard; the final permission of the origins page, and whether it needs a read-only mode, is #206's decision.
2026-10-26 — the origins page becomes configuration (#206)¶
- Range:
5da49c6..479e459 - Pages:
guides/operate.md/operate.pt.md— updated: the screen is Origens and has two sections: the configured sources (add picker, configuration, agenda, Desativar/Ativar, Enfileirar execução, the effective-configuration summary) and the Entradas (manual/CSV read-only, with the link to/acervo/nova). The page isOPERATE, the picker is the existingPATCH, pausing keeps the row and the agenda, a fresh install starts empty, and the health probe counts only the configurable origins.guides/curate.md/curate.pt.md— updated: Ingestão becomes Origens in the Sistema table and section; the page stops being the "operator only" exception (the card isOPERATE, the read routes stay authenticated), and the entry paths remain under Nova descrição in the Acervo.guides/install.md/install.pt.md— updated: a fresh installation has no configured source; the operator adds thepmcdefinition from the picker, and thePUBLIC_SCRAPE_*values are that adapter's fallback when the fields are empty.guides/data-model.md/data-model.pt.md— no-change: the migration is data (a row is deleted when it is still the seed); no column or index moved.adr/— no-change: the origin is still a definition plus a row (ADR 0016); the page now models the distinction instead of changing it.- Freshness. The previous floor ended at the #205 feature commit, whose content the squash
5da49c6carries; this round read the merged pages again and the #206 updates are what closes the range from5da49c6. - Why the round exists. #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,
manual/csvappeared as sources, and the empty state was unreachable. The page now reads the rows, offers the definitions without one, and has a lifecycle. - What the round measured. Full suite 2275 passed, 1 skipped against the test database on
5434 (
postgresql+psycopg2);ruff check,ruff formatandbasedpyrightgreen through the commit hook; the SPA'stsc, ESLint and Vite build green;bun test20 pass;testing/unit/curatorandtesting/unit/docs53 passed;mkdocs build --strictgreen; the contract regenerated (the additiveconfigurableandavailable);alembic upgrade headappliedf1090f825a94against the dev database (thepmcrow kept: it has provenance) and against a fresh scratch database (thepmcrow deleted;manualandcsvkept), andalembic checkreports 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:.render/206-empty.pngis the empty Origens section with Adicionar origem and the two read-only Entradas;.render/206-picker.pngis the picker offering the PMC definition;.render/206-added.pngis the card active with the effective configuration (Espera entre requisições (s): 0.5) and the add action gone;.render/206-paused.pngis the same card paused, with Ativar and "0 ativas";.render/206-health.pngis the health panel showing 0 de 1 origens ativas, only PMC, and Abrir origens. 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. - Not verified, and said so. 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
permissions.tsmirror plus the API guard. The manual/CSV entries are read-only by design, so their pause/re-enable has no control on the page (thePATCHremains) 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 (pmc) 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.
2026-10-26 — one operations screen for the panel and the ledger (#207)¶
- Range:
f6efb38..1c96177 - Pages:
guides/curate.md— updated: Workers de IA became Workers e execuções, one screen with two tabs in the URL (Painel and Execuções). 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.guides/curate.pt.md— updated: the translation of the same three places.guides/operate.md— updated: the SPA table merges/sistema/workersand/sistema/execucoesinto 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.guides/operate.pt.md— updated: the same three places.adr/0016-the-origin-is-a-first-class-object.md— updated: the ingestion is seen in the execution ledger at/sistema/workers, not in a/sistema/execucoesscreen; the object, its row and its route are unchanged.adr/0018-trials-and-the-human-gabarito.md— updated: the trial is distinguishable in the execution ledger (the Execuções tab); the trial semantics are unchanged.guides/data-model.md/data-model.pt.md— no-change: no table, column or index moved.guides/install.md/install.pt.md— no-change: no setting or command changed.- Why the round exists. 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
/sistema/execucoesredirecting there with the search preserved. The configuration stays a screen of its own (#53). - What the round measured.
bun run --cwd apps/curator typecheckandlintgreen; the Vite build green (2117 modules);pytest testing/unit/curator testing/unit/docs53 passed; the commit hook ran ruff, ruff-format and basedpyright green;mkdocs build --strictgreen. Rendered with the headless shell over CDP at 1280×1500 against the Vite dev server and an API on a scratch database (scrinalia_render_207, dropped after):207-panel.pngis the heading Workers e execuções with the two tabs, the runners card and the ten workers;207-panel-enqueued.pngis the panel after one run, with "Execução #1 enfileirada; ver no ledger" and the same link under Última execução;207-ledger.pngis the Execuções tab with the worker filter set and the run row ringed;207-settings-operacao.pngis the landing's Operação tab with four cards and no Execuções card. The link's href was/sistema/workers?aba=execucoes&worker=transfer&run=1, and clicking it landed on that URL with exactly 1 highlighted row./sistema/execucoes?worker=transfer&run=1redirected to/sistema/workers?worker=transfer&run=1&aba=execucoes. Clicking the tab bar itself moved?aba=execucoes&worker=transfer&run=1to?aba=painel&worker=transferand back to?aba=execucoes&worker=transfer: the filter survives, andrunis dropped by a change that is not itself. - Not verified, and said so. 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
permissions.tsmirror plus the API guard, unchanged by this round. The redirect was exercised withworkerandrunonly, not withstatus,origin,fingerprintoroffset; 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.
2026-10-27 — one hint element for the explanations that do not deserve a paragraph (#208)¶
- Range:
1c96177..7843d9e - Pages:
guides/curate.md— no-change: the routes, writes and flows the guide documents did not move. The newHintonly 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.guides/curate.pt.md— no-change: the same translation, for the same reason.guides/operate.md— no-change:WorkersPanel.tsxmoved only to place the existing explanations behind the shared hint; the other source in the range is the Dependabot bump ofastral-sh/setup-uvin.github/workflows/ci.yml, which changes no command, probe or worker behaviour the operator guide describes.guides/operate.pt.md— no-change: the same two sources, the same reason.- Why the round exists. The same second sentence was rendered four ways and chosen per screen,
with the native
titleas the "discreet" default: hover-only, after a delay, never on focus, absent on touch, and unreliable for assistive technology. #208 adds oneHintprimitive — a real button witharia-describedbyand arole="tooltip"panel whose text stays in the DOM while closed — and migrates the first three places.Field.infosits outside the<label>, because a button inside a label is invalid HTML and pollutes the input's accessible name.Tabsuses the primitive throughasChild, so the tab itself is the trigger and the interimtitleplussr-onlytext from #220 is gone. The rule lives in thecurator-uiskill. - What the round measured.
bun run --cwd apps/curator typecheck,lintandbuildgreen (2118 modules);bun test26 pass (20 CSV + 6 hint);pytest testing/unit/curator24 passed;pre-commit run --all-filesgreen. 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:208-conflicts-hint.pngis the threshold tooltip open with the focus on its button,208-conflicts-escape.pngis the same panel closed after Escape while the button keeps focus,208-tab-hint.pngis the Pendentes tab description from the tab itself,208-workers-run.pngis the run-button explanation,208-workers-config.pngis the effective configuration hint (and what caught the inheriteduppercasebefore it shipped), and208-csv-badge.pngis the column badge after a real file was set on the CSV input. The page's tooltip state wasopenon focus andclosedafter Escape; the text was read back from the described panel, not assumed from the screenshot. - Not verified, and said so. 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
happy-dom, not on a touch device. The othertitleattributes remain (180 insrc, three fewer than before this round), because the issue retires the pattern as uses migrate, not in one sweep. The tab hints remain inside thetablistin 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.
2026-10-27 — the copy sweep reaches the rest of the interface¶
- Range:
b75d74e..7e9a5b3 - Pages:
guides/curate.md— updated: the sweep renamed two screens the guide names by label —Excluídas→Histórico de exclusões,Nova descrição→Nova Descrição— and the two purge panels (Ledger das purgas→Registro das purgas,Ledger dos expurgos→Histórico de expurgos). A reader sent toAcervo › Excluídaswould not find the entry. No route, write or flow changed.guides/curate.pt.md— updated: the same four names, in the translation.guides/install.md— updated: the backup section quotes the deletion screen by name and calls it a trail; it isHistórico de exclusões, and it is a record. What the section explains did not move: the snapshot row, no restore, a node with children refused.guides/install.pt.md— updated: the same paragraph, same reason.guides/operate.md— no-change: its only moved source isSystemHealthRoute.tsx, whose copy saiddimensionsandNER/parserfor the effective configuration the page already describes. The page documents runs, queues, retries and the worker catalogue, and it namesledgeras the append-only table, not as a panel — the sweep renamed the screens, not the mechanism.guides/operate.pt.md— no-change: the same source, the same reason.- Why the round exists. The first copy pass covered nine files; the second swept the remaining
fifty-three (commit
7e9a5b3). It replaced the system's vocabulary with the task —ledger→ histórico,staging/AI queue out, rule kinds out of caps,Engine→Motor— 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.workerstays where it names the operations object, which is the exception thecurator-uiskill now records (a0655d4). - What the round measured.
bun run --cwd apps/curator typecheck,lintandtestgreen (26 pass);pytest testing/unit/curator testing/unit/docs53 passed (the copy gate included);basedpyrightgreen on both commits through the pre-commit hook;freshness.pyreports these three pages and no other. - Not verified, and said so. 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.
2026-10-27 — the second copy read, and the screen it renamed¶
- Range:
7e9a5b3..0876ead - Pages:
guides/curate.md— updated: the copy pass renamed/entidades/excecoesfrom "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.guides/curate.pt.md— updated: the same two names, in the translation.guides/install.md— no-change: its only moved source in the range islib/screens.ts, and the install guide names neither the Tags screen nor this one; what moved there is the Tags hint/description and two screen descriptions.guides/install.pt.md— no-change: the same source, the same reason.- Why the round exists. A second, closer copy read over the subject, entity and dossier screens
(
8b3acfc) plus the correction it made necessary (0876ead): 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 caughtapagarandunificaçãoand required the canon'sExcluirandMesclagemagain — and a stray double quote in one JSX text node, which is also what hid that node from the scan. - What the round measured.
tsc, ESLint, 26 bun tests and 24 pytest for the copy commit;ruff check,ruff format --check,basedpyright,bun run contract(no drift),alembic check(no drift) andmkdocs build --strictfor this round.freshness.pyreported these two pages and no other. - Not verified, and said so. No screen was rendered: the evidence is the gates and the diff.
2026-10-28 — the plan becomes the tree it already was¶
- Range:
ba1fb02..8f891d8 - Pages:
guides/curate.md— updated: the plan section now describes the rungs as the treeparent_codeimplies, 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.guides/curate.pt.md— updated: the same paragraph in the translation.- Why the round exists. #233 replaced one flat list indented by
plan.depthwith nested lists and two pure functions (buildPlanTree,planRows); 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. - What the round measured.
tsc, 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.Recolher os decididoswas 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. - Not verified, and said so. 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.
2026-10-28 — the access points are chips, and the string is read before it is written¶
- Range:
8f891d8..c15060d - Pages:
guides/curate.md— updated: the Nova Descrição section now says the access points are chips over the catalogue, that the type-ahead picks an existing spelling and Adicionar keeps the archivist's, and that the summary under the control names what the transfer will discard, create or map to a synonym. It namesPOST /api/v1/taxonomy/tags/previewas the read behind the summary and says the preview removes nothing.guides/curate.pt.md— updated: the same paragraph in the translation.guides/data-model.md— no-change: the repository gained two read-only lookups (get_tag_identities_by_names/by_ids) over existing columns and the unique normalized-name index; no table, column, constraint or relationship changed, so the model page is still true.guides/data-model.pt.md— no-change: the English page it translates did not change.- Why the round exists. #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 Adicionar keeps
a new term, and one read-only summary says what the transfer will do with the whole string. The
wire is still
indexing_points: str | None; the CSV column and the scraper are untouched, and the transfer remains the only writer. - What the round measured.
ruff check,ruff format --check,basedpyright, 2310 pytest passed and 1 skipped against the test database on port 5434, 61 bun tests,tsc, ESLint,bun run contract(the new route),alembic check, the docs coverage suite andmkdocs build --strict. 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. - Not verified, and said so. 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.
2026-10-28 — the producer field copies a name, it does not link¶
- Range:
ee24d79..6ba1d64 - Pages:
guides/curate.md— updated: the Nova Descrição section now describesProdutor(es)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.guides/curate.pt.md— updated: the same paragraph in the translation.- Why the round exists. #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
pmcorigin) 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 Future Ideas milestone. - What the round measured.
tsc, ESLint, 72 bun tests (11 new inapps/curator/tests/entityNameField.test.tsx) and 25 pytest intesting/unit/curator(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,ORG · 23 descriçõesandLOC · 1 descrição), 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. - An observation, not a fix. "gui" returned "rua assungui" (
LOC, 3 descriptions) before "guido ghilardi" (PER, 1): the endpoint orders by usage, which the dossier's entity picker already inherited. Nothing in this change touches that ordering. - Not verified, and said so. 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.
2026-10-28 — the blocked spellings are a screen¶
- Range:
263d936..67f00ad - Pages:
guides/curate.md— updated: the Assuntos table gainsGrafias bloqueadas — /assuntos/grafiasand Tags loses the banned terms;#### Stopwordsmoves out of the Tags section and becomes its own###section, with the two objects stated (meaning × spelling), the reciprocal link and the?aba=stopwordsredirect; the Similaridade, Classificar and Não é assunto paragraphs now name the screen instead of the tab, and the Não é assunto section says which of the two each one is.guides/curate.pt.md— updated: the same translation, in the SPA's vocabulary.guides/data-model.md— no-change beyond the name: two sentences said "the Stopwords screen" forsave_stopwords, and the screen is nowGrafias bloqueadas; the table, the columns and the ledger paragraphs still hold.guides/data-model.pt.md— updated: the same two references.- Why the round exists. #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 grafia, not a term), and made the two screens name each other.
- What the round measured.
tsc, ESLint, the Vite build, 72 bun tests,basedpyright(0 errors) and 56 pytest acrosstesting/unit/curator(27, including the three new copy rules) andtesting/unit/docs(29). The screens were rendered againstscrinalia_renderwith the headless shell:/assuntos/grafiaswith 208 blocked spellings in three axes, a ban whose origin is a verdict (veio de “não é assunto”) carrying thever em Não é Assuntolink, the danger cardExcluir as tags com esses nomesdisabled until the impact preview, and the ledger below it; the verdict screen with the reciprocalGrafias bloqueadasbutton and the disclosure sentence naming where the tags are deleted; and/assuntos/excecoes?termo=…opening its ledger filtered (1 de 6 termos vetados) and scrolled to it, which is the landing the new link promises./assuntos/tags?aba=stopwordswas followed and answered/assuntos/grafias. - What was not verified. The write path was exercised only to plant the fixture (
POST /subject-exclusionsfor a synthetic term, which created theEXCLUSIONban 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 (scrinalia_render) and was migrated toheadfor the round; the synthetic term, its ban and the temporary account were left in it and belong to nobody's collection.
2026-10-28 — the interface in two languages¶
- Range:
67f00ad..ba20a13 - Pages:
guides/curate.md— updated: a new The interface speaks two languages 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).guides/curate.pt.md— updated: the same translation, in the SPA's vocabulary.guides/install.md— updated: one sentence after the first administrator: the interface opens in Portuguese and each account can switch to English in the menu.guides/install.pt.md— updated: the same sentence.adr/0020-the-interface-speaks-two-languages.md— no-change: the decision already states the account locale, the browser fallback and the server catalogue; the sweep implemented it without changing it.- Why the round exists. #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
src/i18n/. - What the round measured.
tsc, ESLint and the Vite build green; 72 bun tests;pytest2 312 passed, 1 skipped; the copy gate now has noPENDINGlist: every file outsidesrc/i18n/must have no Portuguese, with the two declared data exceptions (the storedSEM TÍTULOsentinel and a URL). The whole sweep was rendered againstscrinalia_render(4 842 descriptions) with the headless shell: 56 screenshots, the 28 screens inpt-BRanden. The render found what the accent detector had missed — the English inbox still said Contagens geradas em and formatted the date with a hardcodedpt-BR; the line is now catalogue-backed and goes throughformatDateTime, and the re-rendered inbox says Counts generated at 10/28/26, 9:10 PM. 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. - Not verified. 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.
2026-10-28 — the origin panel action opens the card it lives in (#234)¶
- Range:
d746689..e2c5018 - Pages:
guides/operate.md— updated: the/sistema/ingestaoparagraph now says what Configurar and Agenda 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 declaresapps/curator/src/routes/OriginsRoute.tsxandcomponents/ingestion/OriginPanel.tsxamong itssources:, because it documents this screen and no freshness report could see it before.guides/operate.pt.md— updated: the same sentence, in the SPA's vocabulary.- Why the round exists. #234 was a state split in two: the action buttons set
panel, 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 intouseOriginPanel, addsaria-expanded/aria-controlsand a pressed state to the two buttons, and scrolls the panel into view. - What the round measured.
tsc, ESLint and 79 bun tests green (72 before, 7 inoriginPanel.test.tsx). The screen was rendered: the headless shell over CDP against the dev server and the API onscrinalia_render, with a session row inserted for the round and deleted after. Four shots under.render/: the card collapsed; Configurar 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 Agenda reopening the card on the schedule form. Measured over CDP in those states: the heading stays aty=16, the window and the content column stay atscrollTop=0, and the configuration form's save button lands aty=761— the panel fits the 1000px viewport, soblock: "nearest"had nothing to move; the scroll itself is pinned by the unit test, not by these shots. - Not verified. No write was exercised — Salvar configuração, Salvar agenda and Pausar
were not clicked, and the operator-permission case (a curator's read-only card) was not rendered.
The render used the one configured origin (
pmc) in the scratch database; the long-card below-the-fold case is pinned by the unit test'sscrollIntoViewassertion, not by a screenshot of a card taller than the viewport.