Modelo de dados¶
Esta página trata do que os dados significam: qual camada é dona de qual fato, para que serve cada tabela e quais escritas são decisões que uma pessoa pode desfazer. Não é um guia de instalação — para o ciclo de vida do schema veja Instalação e implantação, para executar os workers veja Operação e para as telas sobre essas tabelas veja Curadoria.
O schema é propriedade do Alembic (migrations/), e os modelos SQLAlchemy em
src/scrinalia/domains/*/models/ são a fonte única de verdade. uv run alembic check não pode
acusar drift; uma tabela que existe apenas em uma migration é um defeito.
Domínios e camadas do pipeline¶
O sistema tem quatro domínios em src/scrinalia/domains/. Três deles são as camadas que um registro
atravessa, nesta ordem:
ingestion— a fila de coleta e o payload intocado (scraping_queue,raw_data);staging— o payload convertido em colunas ISAD(G) tipadas e higienizadas (staging_documents);archive— a descrição final enriquecida, os catálogos, os ledgers da IA e a revisão humana (archive_*,domain_*).
O quarto domínio, identity, não é uma camada desse pipeline (ADR 0009). Ele é dono das contas,
das sessões e do mapa de papéis — as tabelas que descrevem a instalação, não o acervo. Um registro
nunca "passa por" identity.
site de origem
│ coleta │ parse + limpeza │ enriquecimento + revisão
▼ ▼ ▼
ingestion ───────────▶ staging ───────────────────▶ archive
(scraping_queue, (staging_documents) (archive_documents, …)
raw_data)
identity ── contas, sessões, papéis (não é camada do pipeline)
Cada camada identifica um registro por um hash do que leu por último, para que uma nova execução
saiba se algo mudou: raw_data.content_hash, staging_documents.raw_content_hash e
archive_documents.staging_content_hash. A chave de staging → archive é o hash do registro
parseado, não do payload bruto, então uma correção no parser chega ao archive mesmo quando os
bytes da origem não mudaram.
Tabelas¶
Todas as tabelas de Base.metadata. Para enumerá-las a partir do código:
uv run python -c "from scrinalia.core.base import Base; import scrinalia.domains.archive.models, scrinalia.domains.identity.models, scrinalia.domains.ingestion.models, scrinalia.domains.staging.models; print('\n'.join(sorted(Base.metadata.tables)))"
Ingestão — o que a origem enviou, sem tratamento¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
ingestion_origins |
O ciclo de vida, a configuração e a agenda de uma origem: o que o operador edita sem deploy. | code (chave primária), enabled, config (JSONB: URLs e espera; a origem csv não tem), options (JSONB: a política de execução), cron, timezone, next_run_at, last_enqueued_at, last_skipped_at, updated_by, updated_by_user_id |
scraping_queue |
Uma linha por identificador descoberto na origem, com seu estado de extração e contagem de tentativas. | description_id (chave de negócio, única), origin_code, scrape_status (ScrapeStatus), discovered_at, last_scraped_at, retry_count, last_error_message |
raw_data |
O payload exato devolvido pelo adaptador, sem tipagem nem limpeza. | description_id (único), origin_code, content_hash, raw_title, payload (JSONB), created_by, created_by_user_id |
scrape_status distingue uma falha transitória (NETWORK_ERROR, com nova tentativa) de uma
permanente (NOT_FOUND, FATAL_ERROR). raw_data.payload é a evidência do que a origem realmente
enviou; nada a jusante o lê diretamente. raw_data.created_by é preenchido pelas origens de
formulário (manual, csv): uma pessoa digitou ou enviou o registro, enquanto um scraper não tem
autor — o autor é a origem.
A origem é proveniência, não identidade. O origin_code está em cada camada e no ledger de
execuções, mas o description_id continua sendo a chave de negócio global; um adaptador que pudesse
colidir prefixa os próprios ids (manual-<uuid>). A definição da origem — o adaptador e o
SourceSchema que diz qual rótulo preenche qual coluna do staging — fica ao lado do adaptador, em
código; a linha acima é o ciclo de vida, e uma linha cujo código saiu do registro continua legível mas
não roda (ADR 0016). O transform de staging resolve o schema por registro, então uma execução pode
ler várias origens; a origem é deliberadamente excluída de StagingRecord.parsed_content_hash(), ou
toda linha pareceria mudada uma vez por uma coluna que nenhum worker de IA escreve.
Staging — o registro parseado¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
staging_documents |
A descrição higienizada e tipada antes de qualquer IA tocar nela: a fonte única de verdade higienizada. | description_id (chave primária), origin_code, raw_content_hash, title, document_date, date_expression, original_url, reference_code, parent_reference_code, hierarchy_path, level, as colunas de texto ISAD(G), raw_metadata (JSONB), created_at, updated_at |
parent_reference_code é opcional de propósito: uma origem que entrega um filho antes do pai deixa a
descrição órfã e marcada em vez de derrubar o lote. raw_metadata guarda as chaves que não foram
mapeadas para uma coluna.
Archive — a descrição e tudo ao redor dela¶
A descrição¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_documents |
A descrição final, limpa e enriquecida, servida aos usuários e enriquecida pelos workers de IA. | description_id (chave primária), origin_code, original_title, final_title, document_date, date_expression, original_url, summary, staging_content_hash, reference_code, level_id, typology_id, parent_id, parent_reference_code, path, as colunas de texto ISAD(G), review_status, is_anomaly, anomaly_reasons, is_published, execution_log (JSONB), embedding (vector(384)), search_vector (gerada), timestamps |
As colunas de texto ISAD(G) são o registro inteiro, não um subconjunto: dimension_support,
producers, admin_bio_history, admin_archival_history, provenance, scope_content,
appraisal_destruction, accruals, arrangement, access_conditions, reproduction_conditions,
language_name, physical_characteristics, finding_aids, originals_location, copies_location,
related_units, publication_notes, conservation_notes, general_notes, archivist_notes,
rules_conventions e description_dates. O transform de staging sempre as interpretou; a
transferência copiava um subconjunto até o acervo ganhar o resto, e é por isso que o dossiê agora
mostra e edita a ficha por área.
parent_reference_code é o pai que a origem declarou, guardado mesmo quando o vínculo ainda não
pode ser resolvido: a transferência marca o filho como pendente, e o código vivia só dentro do valor
do carimbo hierarchy_parent_v1. date_expression e original_url são evidência, não descrição
editável: como a fonte escreveu a data, e a página de onde o registro foi lido.
language_name guarda a grafia do catálogo. As origens não concordam sobre como escrever um
idioma — a coleção de referência declarava Português Brasil em 78 descrições e Português/Brasil em
22 —, então a transferência resolve o texto declarado contra domain/language_catalog.py (sem acento,
caixa ou pontuação, com as variantes declaradas como apelidos) e grava o nome canônico. Um idioma que
o catálogo não conhece é mantido exatamente como a fonte escreveu: o catálogo nunca troca a palavra de
uma fonte por um palpite, e é para isso que existe a marcação (fora do catálogo) do dossiê.
staging_documents.language_name guarda o texto declarado, como o texto do nível.
Três propriedades desta tabela sustentam o modelo:
parent_idé uma autorreferência comON DELETE RESTRICT. Um fundo é uma descrição arquivística como qualquer outra, então não ganha tabela própria; e um nó com filhos não é apagável de passagem, porque opathde cada descendente carrega os ids dos seus ancestrais.pathé o conjunto materializado de ids dos ancestrais (2368.2732.51931), mantido pelo serviço e nunca pela IA. Ele transforma "todos os descendentes de X" em um únicoLIKE 'x.%'indexado.review_status(ArchiveReviewStatus) é o ciclo de vida IA/humano:PENDING_AI,AI_APPROVED,NEEDS_REVIEW,HUMAN_APPROVED,REJECTED.HUMAN_APPROVEDbloqueia reescritas da IA.
Difusão não é revisão
is_published é coluna própria, deliberadamente não review_status = HUMAN_APPROVED. Revisão é
uma afirmação sobre o registro; publicação é uma decisão sobre o que expor. Reutilizar o status
faria uma correção de errinho equivaler a publicar, e travaria todo documento publicado contra a
reescrita da IA. Nada é publicado por padrão, então a superfície de difusão nasce vazia.
Assuntos, categorias e entidades¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_tags |
Um termo de assunto do acervo. | tag_id, name (único), macro_category_id, ai_confidence_score, execution_log (JSONB) |
archive_macro_categories |
A categoria de assunto (o eixo do "sobre o quê"). | category_id, name (único), description, classifier_label, is_active |
archive_entities |
Uma entidade nomeada (pessoa, organização, lugar) encontrada por NER ou inserida à mão. | entity_id, name (único), entity_type |
archive_entity_merge_log |
Uma linha por entidade absorvida (um merge) ou por nome renomeado (uma renomeação), retratada antes de qualquer mudança. | kind (MERGE/RENAME, com check), canonical_renamed_from, renamed_from_synonym_created, undone_at; undo_entity_merge restaura a linha, seus vínculos, suas grafias e o nome que a canônica perdeu. |
archive_document_tags |
Quais tags uma descrição carrega. | chave composta (description_id, tag_id) |
archive_document_entities |
Quais entidades uma descrição carrega, e quem escreveu cada vínculo. | chave composta (description_id, entity_id), origin (ArchiveEntityOrigin: AI, HUMAN ou UNKNOWN), created_at (nulo quando o vínculo é anterior à coluna) |
archive_ai_review_queue |
A fila de auditoria da IA; o lado da decisão de uma colisão tag × entidade. | anomaly_type (AnomalyType), status (ArchiveReviewStatus), context_payload (JSONB), llm_decision, llm_confidence, llm_reason |
archive_tags.macro_category_id guarda no máximo uma categoria de assunto, e esse é todo o eixo de
assunto: uma tag está numa categoria, foi declarada não-assunto, ou ninguém decidiu (ADR 0013).
O que um nome é quando não é um sobre-o-quê — um lugar, um produtor, uma pessoa — fica no eixo de
entidades, onde ele tem um entity_type que a busca e a projeção pública leem; uma grafia que é tag
e entidade ao mesmo tempo se resolve pela resolução de conflito tag × entidade, que bane a grafia
perdedora e guarda um ledger. archive_tag_facets guardava essa distinção como um segundo estado da
tag; seu único leitor era o selo de estado que ela alimentava, e ela foi removida.
A identidade de uma tag e de uma entidade é a grafia normalizada: lower(btrim(name)) é único nas
duas tabelas, ao lado do name único. Sem essa regra, uma linha guardada em Caixa Alta e a linha em
minúsculas do mesmo nome são duas linhas, e o caminho de inserção — que normaliza a grafia e procura
os identificadores existentes por ela — nunca enxerga a primeira.
O arranjo¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_description_levels |
A escada de níveis de descrição (os degraus NOBRADE/ISAD(G)), propriedade do arquivista. | level_id, ordinal (único), code (único), name (único), description, aliases, requires_parent, allows_children, is_active |
archive_hierarchy_node_plans |
Um degrau que os códigos de referência implicam, e a decisão do arquivista sobre ele: proposta, não escrita. | plan_id, code (impressão digital única), depth, parent_code, document_count, declared_levels, flags, existing_description_id, level_id, title, reference_code, status, collapse_into_code, materialised_description_id, decided_by, decided_at |
archive_hierarchy_materialisation_log |
O ledger de uma execução de materialização, com detalhe suficiente para revertê-la. | materialisation_id, created_nodes (JSONB), rung_map (JSONB), previous_state (JSONB), changed_by, changed_by_user_id, undone_at, undone_by, undone_by_user_id |
requires_parent significa "um nó neste nível não pode ser raiz"; allows_children significa "um nó
neste nível é folha". A carga de staging registra um nível desconhecido como não classificado e
segue; já uma tipologia escolhida por uma pessoa é uma afirmação que o catálogo precisa responder.
A trilha de curadoria da descrição¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_document_revisions |
Toda edição humana de uma descrição, antes e depois. | revision_id, description_id, changed_by, changed_by_user_id, changes (JSONB), note, created_at |
archive_document_deletions |
O ledger da única escrita destrutiva sobre o acervo. | deletion_id, description_id, reference_code, title, level_name, snapshot (JSONB), children_count, deleted_by, deleted_by_user_id, note, deleted_at |
O description_id em archive_document_revisions é ON DELETE CASCADE, então uma revisão escrita
para um documento apagado morre com ele. Por isso archive_document_deletions não tem chave
estrangeira para archive_documents: a linha que ela nomeia sumiu por definição, e este ledger é o
que sobrevive. Ele não é caminho de restauração — não existe código que grave o snapshot de volta.
Tabelas de governança¶
Estas tabelas se chamam domain_* embora vivam em domains/archive/: são afirmações sobre o domínio
do acervo, não sobre a instalação.
| Tabela | Para que serve | Colunas principais |
|---|---|---|
domain_stopwords |
Termos banidos pela curadoria, com o eixo que eles deixam, quem plantou o banimento e como o banimento casa com um nome. | id, word (único), word_scope (StopwordsScope: TAG, ENTITY, ALL), source (HUMAN/CONFLICT/EXCLUSION, com check), match_mode (StopwordMatch: TOKEN/EXACT, com check; só os eixos ENTITY/ALL leem) |
domain_ner_exclusions |
Termos que a curadoria decidiu pertencerem ao eixo TAG, não a entidades nomeadas; casam pela grafia exata, nunca por token. | id, term (único), reason, source (JUDGE/HUMAN), tag_id |
domain_subject_exclusions |
Termos que a curadoria decidiu não serem assunto de forma alguma, e se esta linha plantou o banimento no escopo TAG. |
id, term (único), reason, source (HUMAN/RULE), ban_created |
domain_synonyms |
Grafias mapeadas para uma tag ou entidade canônica. | id, synonym_name, category (TAG/ORG/LOC/PER), canonical_tag_id, canonical_entity_id |
domain_text_templates |
Trechos repetidos que a curadoria mantém fora do texto da IA. | template_id, text, fingerprint (único), variants, action, replacement, scope, status, occurrence_count, sample_document_ids |
archive_cleaning_rules |
Regras de regex cadastradas por um arquivista. | rule_id, rule_name, rule_kind (REWRITE/VALIDATE/LLM_CHECK), target_column, regex_pattern, replacement_string, anomaly_reason, engine_name, preset, is_active |
domain_synonyms tem uma checagem de arco exclusivo: uma linha TAG aponta para uma tag e não para
uma entidade, e as outras categorias apontam para uma entidade e não para uma tag. Canonicalizar uma
grafia apaga a linha antiga, e é por isso que a busca da taxonomia lê esta tabela também.
domain_text_templates.scope (EMBEDDING, NER, TITLE) diz qual consumidor deixa de ler um
trecho. A separação existe porque a medição mostrou que um trecho pode ajudar um consumidor e
prejudicar outro: remover um prefixo de título do texto embutido piorou o ranking, sendo exatamente
o que a sugestão de título precisa.
archive_cleaning_rules.rule_kind separa REWRITE (o worker de limpeza substitui a ocorrência) de
VALIDATE/LLM_CHECK (o validador de qualidade apenas sinaliza). O worker de limpeza filtra
REWRITE explicitamente; sem isso, uma regra de validação reescreveria o texto.
O vocabulário do acervo (dado, não código)¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_arrangement_vocabulary |
Um token de arranjo e o nome que a proposta de hierarquia sugere para o seu degrau. | term_id, token (único, normalizado), display_name, is_active |
archive_collection_terms |
Um termo que o acervo carrega e que não é assunto, com o eixo para onde ele vai. | term_id, (term, kind) único, kind (CollectionTermKind), is_active |
token é tanto um token de código único (ED, ALFA) quanto um código inteiro (ACERVO RAIZ). A
proposta lê o código inteiro primeiro e recorre ao seu último token, então uma linha de código
completo vence uma linha de último token. CollectionTermKind é DISTRICT, MUNICIPALITY, STATE,
REGION, COUNTRY ou PERSON; um tipo de lugar e PERSON marcam os termos que o eixo de
entidades carrega (LOC, PER), que é sobre o que o eixo de assunto para de adivinhar — eles não
são um estado da tag.
Operação¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_worker_settings |
A configuração padrão persistida de um worker. | worker_name (chave primária), engine_name, preset, db_batch_size, options (JSONB), updated_by, updated_at |
archive_worker_settings_revisions |
Trilha de auditoria das escritas de configuração, uma linha por escrita. | id, worker_name, before (JSONB), after (JSONB), changed_by, changed_by_user_id, changed_at |
archive_worker_runs |
Uma linha por execução de worker, tanto pela CLI quanto pelo painel; desde a ADR 0014 uma linha QUEUED é também o item de fila que um runner reivindica. Uma execução de ingestão nomeia a origem que processou em origin_code; a de um worker de archive a deixa nula. Um ensaio (trial = true, ADR 0018) é uma comparação de configurações que não escreve conteúdo arquivístico e carrega as medições em trial_result; ele ocupa a vaga do worker como qualquer execução, mas a "última execução" do painel o ignora. |
id, worker_name, origin_code, status (WorkerRunStatus), trigger (WorkerRunTrigger), trial, requested_by, requested_by_user_id, engine_name, preset, request (JSONB, a requisição validada), config (JSONB), options (JSONB), trial_result (JSONB), attempt, retry_of, not_before, queued_at, runner_id, started_at, heartbeat_at, lease_expires_at, cancel_requested_at, cancel_requested_by, cancel_requested_by_user_id, finished_at, duration_ms, error, error_fingerprint (gerada) |
archive_worker_schedules |
A agenda de um worker: um cron de cinco campos num fuso explícito, o request pinado que ela roda e a próxima execução. | worker_name (chave primária), enabled, cron, timezone, request (JSONB, a requisição validada), next_run_at, last_enqueued_at, last_skipped_at, updated_by, updated_by_user_id, created_at, updated_at |
archive_worker_runners |
O registro dos runners vivos: quem está conectado, quais workers aceita e quando deu o último sinal. | runner_id (chave primária), name, hostname, version, capabilities (array de texto), concurrency, heartbeat_seconds, started_at, last_heartbeat_at, stopped_at |
archive_api_errors |
As falhas HTTP inesperadas da própria API. | id, occurred_at, request_id, method, path, status_code, message, error_fingerprint (gerada) |
archive_worker_settings é uma linha parcial de propósito: um campo deixado NULL continua
seguindo o código, então a precedência é argumento explícito > esta linha > o default da assinatura.
archive_worker_runs guarda a configuração resolvida — config da engine, options do próprio
worker, resolvidas quando a execução começa — então a linha mantém o sentido quando um
preset muda no código. Desde a ADR 0014 o ledger de execuções é também a fila: uma linha QUEUED
carrega a request validada, para um runner reproduzir a execução sem o processo que a aceitou, e o
claim grava runner_id, started_at, heartbeat_at e lease_expires_at com o relógio do
PostgreSQL. O runner renova o lease; um claim cujo lease vence é marcado INTERRUPTED pelo próximo
runner, nunca pelo boot da API. archive_worker_runners é o registro que o painel lê para responder
se um runner está vivo e se ele pode rodar determinado worker. Deliberadamente não há chave
estrangeira de nenhuma das duas para uma tabela de workers: workers são código, não linhas.
archive_api_errors registra apenas falhas inesperadas — um 404, um 409 e um 422 são respostas que
a API deve a um cliente, e registrá-las enterra os defeitos de verdade.
Identity — a instalação, não o acervo¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
auth_users |
Uma pessoa que pode entrar no sistema. | user_id, email (normalizado, único), name, password_hash (argon2id), role (Role), is_active, must_change_password, failed_attempts, locked_until, last_login_at |
auth_sessions |
Um login vivo, revogável. | session_id, user_id, token_hash (SHA-256, único), created_at, last_seen_at, expires_at, revoked_at, user_agent, ip_address |
Uma conta é desativada (is_active), nunca apagada: todo ledger do acervo guarda quem decidiu o quê,
e as colunas *_user_id são ON DELETE SET NULL, então apagar a conta apagaria o quem de decisões
que continuam valendo. auth_sessions guarda apenas o SHA-256 do token, nunca o token, então um dump
do banco não pode ser reexecutado como cookie. O ON DELETE CASCADE de auth_sessions para
auth_users é correto aqui e em nenhum outro lugar deste domínio: uma sessão não tem sentido sem a
sua conta.
Idempotência: execution_log e carimbos versionados¶
Todo worker de IA é reexecutável. Em vez de deixar uma coluna de negócio nula e torcer, cada worker
grava uma chave versionada em um execution_log JSONB, e sua consulta de pendentes filtra pela
ausência (ou diferença) dessa chave:
- workers de documento carimbam
archive_documents.execution_log; worker_macro_categorycarimbaarchive_tags.execution_log, porque sua unidade de trabalho é a tag.
As chaves canônicas, definidas uma única vez em worker_stamp.py:
| Chave do carimbo | Gravada em | Significado |
|---|---|---|
worker_ner_v2 |
archive_documents |
O valor é a identidade do extrator que rodou |
worker_typology_classifier_v2 |
archive_documents |
O valor é a identidade do classificador que rodou |
cleaning_rule_{id} |
archive_documents |
A regra dinâmica de limpeza com aquele id já rodou |
worker_macro_category_v1 |
archive_tags |
O valor é o conjunto de rótulos composto com a identidade do classificador |
worker_quality_validator_v1 |
archive_documents |
O valor é a identidade da regra LLM_CHECK ativa, ou de "sem modelo" |
worker_embedding_v1 |
archive_documents |
O valor é o MD5 do texto embutido seguido de : e da identidade do modelo |
thumbnail |
archive_documents |
Miniatura no armazenamento de objetos; a fila é guiada por storage_thumbnail_uri, então esta chave é declarada mas não gravada |
thumbnail_failed |
archive_documents |
O download da miniatura falhou; o valor é ERROR; uma execução simples a ignora, force a retoma |
hierarchy_parent_v1 |
archive_documents |
O valor é PENDING:<code> enquanto o pai declarado falta e DONE:<description_id> depois de vinculado |
curated_macro_category |
archive_tags |
O valor é quem, quando e o destino de uma decisão de assunto de um curador |
As consultas de polling são servidas por índices GIN sobre a coluna JSONB: ix_archive_exec_log em
archive_documents e ix_archive_tags_exec_log em archive_tags. Um worker altera o dicionário e
precisa chamar flag_modified para que o SQLAlchemy perceba a mudança em vigor; esquecer isso é como
um carimbo é gravado em memória e nunca no banco.
A maioria dos carimbos guarda um status (DONE, ERROR). Os cinco que chaveiam uma fila por quem
produziu a resposta são um tipo diferente (WorkerStamp contra IdentityStamp), e é o tipo que
impõe a diferença: um slot de identidade só aceita um valor que o próprio fingerprint_pattern dele
aceite, então um status levanta ValueError em vez de ser gravado, e uma falha passa por
mark_failed. Esse valor é a identidade do produtor — uma impressão digital da engine, do preset,
da configuração resolvida da engine, das versões instaladas das bibliotecas daquele eixo e das opções
comportamentais declaradas (producer_fingerprint em worker_stamp.py). Uma unidade carimbada por
outro produtor volta a estar pendente sozinha:
worker_ner_v2,worker_typology_classifier_v2eworker_quality_validator_v1guardam essa identidade — a última tirada da engine da regraLLM_CHECKativa, ou de "sem modelo" — então outra engine, outro preset, outro limiar, outro conjunto de colunas ou outra versão de dependência reenfileira as unidades.forceé o caminho de volta de um motivo que um hash não vê (uma regra editada, uma exclusão curada, um prompt).worker_embedding_v1compõe essa identidade com o MD5 do texto embutido, calculado pelo PostgreSQL (func.md5(...)sobre o texto efetivo) na mesma expressão que constrói o texto (embedding_stamp_sql), então uma mudança de texto — inclusive uma edição humana — e um bump de modelo ou de dependência devolvem o documento à fila sozinhos.worker_macro_category_v1compõe com o hash do conjunto de rótulos contra o qual a tag foi classificada, então reescrever o rótulo de um curador reenfileira a tag.hierarchy_parent_v1guarda"PENDING:<code>"enquanto o filho espera pelo pai que a origem declarou e"DONE:<description_id>"quando o vínculo existe, então a nova tentativa lembra qual pai faltava.curated_macro_categoryguarda quem, quando e o destino de uma decisão de assunto de um curador — texto livre, não um status, então uma tag que uma pessoa classificou se lê como curada, e não como uma que a máquina acertou.
Os cinco slots de identidade foram reescritos uma vez por
a7c1e5b9d3f2_make_the_legacy_identity_stamps_read_as_unknown.py: todo "DONE" num deles — na chave
atual ou numa aposentada (worker_ner_v1, worker_typology_classifier_v1) — virou
PRODUCER_UNKNOWN, um nome que se lê como o que é e que deliberadamente não é uma impressão digital,
então identity_stamp_conditions continua chamando essas unidades de pendentes. A migração não
inventa produtor nem toca em chave ausente: os mais de 3.100 documentos sem slot de NER continuam sem
nenhum, porque o que é verdade sobre eles é que nada registrou execução. Ela também deixa as linhas do
embedding em paz: elas carregam o MD5 puro do texto que embutaram, de antes de a identidade ser
composta no valor, e o hash do texto é um fato, enquanto PRODUCER_UNKNOWN o trocaria por um falso. O
painel o lê como "sem produtor" pelo próprio padrão do slot.
Uma exceção documentada
worker_embedding deliberadamente não usa ai_writable_documents(). O embedding é um índice
derivado do texto, e não conteúdo arquivístico, então um documento HUMAN_APPROVED cujo texto
mudou precisa ser re-embutido, ou a busca semântica serviria um vetor velho. Ele grava apenas a
coluna embedding e o seu próprio carimbo, e documentos REJECTED são pulados.
Governança bidirecional¶
Uma colisão tag × entidade tem dois veredictos possíveis, e as duas direções são guardadas em tabelas diferentes de propósito.
| Veredicto | Onde a decisão é guardada | O que significa |
|---|---|---|
| A favor da entidade | domain_stopwords (escopo de tag) |
O nome da tag é ruído no eixo de assunto e é retirado dele |
| A favor da tag | domain_ner_exclusions |
O termo é um assunto legítimo; uma extração NER dele é falso positivo |
domain_stopwords.word é único, então um termo vive em exatamente um eixo. Banir de novo um termo
na tela Grafias bloqueadas o move (save_stopwords é um upsert em word) em vez de deixar a tela mostrando
um eixo do qual o arquivista acabou de sair. word_scope é TAG, ENTITY ou ALL, e source
(HUMAN, CONFLICT ou EXCLUSION) diz quem plantou o banimento — a origem que a prévia da purga lê
para separar um termo banido com o acervo em mente de um digitado à mão.
As duas escritas de um mesmo banimento não são a mesma escrita. save_stopwords move o escopo,
porque é o arquivista mudando de ideia na tela; TagRepository.ensure_tag_ban — o que a tela Não é
assunto e a resolução de conflito usam — nunca estreita: um veto ENTITY vira ALL, e um
TAG/ALL existente fica como está. Escrever TAG por cima de um veto ENTITY pararia silenciosamente
a extração NER de um nome que o arquivista tinha mantido de propósito, e é por isso que o acoplamento
usa a segunda escrita.
TagRepository.get_stopwords_with_source() lê somente o escopo TAG/ALL. Um veto com escopo
ENTITY é um banimento da extração NER, não uma afirmação sobre o eixo de assunto; lê-lo ali já fez a
purga da curadoria apagar tags que o curador tinha mantido de propósito. É por isso que as duas
direções não podem ser colapsadas em uma tabela ou um escopo só.
domain_ner_exclusions é uma decisão, não ruído: carrega reason, source (JUDGE para o juiz
de conflito por LLM, HUMAN para um curador) e o tag_id que a justifica. O tag_id é
ON DELETE SET NULL, então apagar a tag não reabre silenciosamente o falso positivo que esta linha
existe para evitar. A linha em si não apaga nada: as entidades já extraídas da grafia saem no
expurgo de NER, um ato separado com o seu próprio impacto na frente e o seu próprio ledger
(archive_ner_purge_log).
domain_subject_exclusions tem a mesma forma para o outro eixo: um termo que a curadoria decidiu não
ser assunto de forma alguma. O guardião determinístico cobre o que tem forma reconhecível (um ano
solto, um placeholder, uma rua, uma medida); a metade semântica (pessoas, vista aérea) é uma
decisão, e vive aqui com source (HUMAN ou RULE). Confirmar também bane a grafia no escopo
TAG, para a próxima ingestão não recriar a tag; o banimento não apaga nada, e ban_created registra
se esta linha o plantou, o que torna o desfazer exato. A escrita recusa um termo cuja tag já está
numa categoria — essa categoria é um veredito humano sobre a tag, e uma afirmação sobre um termo não
pode desarquivá-lo. Um termo excluído continua sendo uma tag do acervo, alcançável pela busca até a
purga — um ato separado — removê-la.
Os ledgers, um a um¶
Um ledger é uma tabela cujo trabalho é tornar uma escrita explicável ou reversível. Eles não são intercambiáveis, e só alguns têm desfazer.
| Ledger | O que registra | O seu desfazer |
|---|---|---|
archive_taxonomy_merge_log |
Uma linha por tag absorvida, fotografada antes de qualquer mudança. | undo_merge restaura a linha, os seus vínculos, a sua classificação e o estado das grafias. |
archive_entity_merge_log |
Uma linha por entidade absorvida (um merge) ou por nome renomeado (uma renomeação), fotografada antes de qualquer mudança. | undo_entity_merge restaura a linha, os seus vínculos, as suas grafias e o nome que a canônica perdeu; uma linha de renomeação só devolve o nome. |
archive_stopword_purge_log |
Uma linha por tag que a purga de stopwords apagou, fotografada antes do delete: a linha, os documentos que a carregavam e as grafias que apontavam para ela. | undo_stopword_purge restaura a linha, os seus vínculos e as suas grafias; o banimento continua, então a próxima purga lista a tag de novo. |
archive_ner_purge_log |
Uma linha por entidade que o expurgo de NER apagou, fotografada antes do delete: a entidade, os documentos que a carregavam, as grafias que apontavam para ela e o excluded_term que a justificou. |
DELETE /entities/ner-exclusions/purge-log/{id} restaura a entidade, os seus vínculos e as suas grafias; o veto continua, então o próximo expurgo lista a entidade de novo. |
archive_hierarchy_materialisation_log |
Uma linha por execução de materialização: os nós que ela criou e o pai/caminho anterior de cada linha que ela mudou. | O desfazer restaura previous_state e então remove created_nodes. |
archive_conflict_resolution_log |
Uma linha por resolução tag × entidade: o snapshot do perdedor, os vínculos transferidos, as grafias que apontavam para o perdedor e o banimento que ela plantou. | DELETE /conflicts/resolutions/{id}. |
archive_document_deletions |
Uma linha por descrição apagada, com todo o snapshot ISAD(G). | Sem desfazer. O snapshot é evidência; nada o grava de volta. |
archive_worker_runs |
Uma linha por execução de worker, CLI e painel igualmente. | Sem desfazer — é um histórico de execução, não uma mudança no acervo. |
archive_api_errors |
Uma linha por falha HTTP inesperada. | Sem desfazer — um ledger de erros é um rastro. |
Dois detalhes tornam os ledgers reversíveis exatos em vez de aproximados. created_link_ids registra
os vínculos que a operação criou; tanto os dois merges quanto a resolução de conflito vinculam com
ON CONFLICT DO NOTHING, então um desfazer que apagasse todo vínculo removeria vínculos que já
existiam antes dele. ban_created cumpre o mesmo papel para as duas escritas que plantam um banimento
— a resolução de conflito e a exclusão de assunto: o desfazer só pode remover o banimento quando esta
linha é a que o plantou, senão reverter uma decisão levantaria um banimento que uma decisão anterior
fez. A exclusão de assunto acrescenta um segundo guardião — o source do banimento precisa ainda
nomeá-la (EXCLUSION), então uma grafia que uma pessoa rebaniu na tela Grafias bloqueadas mantém o seu
banimento — enquanto o desfazer da resolução de conflito se guarda apenas por ban_created. Para um
merge, synonym_created e synonym_previous_tag_id (tag) / synonym_previous_entity_id (entidade)
capturam o estado das grafias antes do merge, e repointed_synonym_names registra as grafias que
apontavam para a linha absorvida e foram movidas para a canônica. undone_at mantém o desfazer de uso
único e mantém o rastro legível depois: a linha nunca é apagada.
archive_ai_review_queue é a companheira de archive_conflict_resolution_log, não um ledger de
escritas: ela guarda a decisão (o veredicto do juiz ou o do humano), enquanto o log de resolução
guarda a escrita. Ela também carrega AnomalyType.SUBJECT_LOW_CONFIDENCE para tags que o
classificador de assunto não conseguiu posicionar, e AnomalyType.TYPOLOGY_LOW_CONFIDENCE para
descrições que ele recusou classificar — é nessa linha que fica o score que a execução viu, porque
typology_id continua NULL tanto quando o classificador recusou quanto quando nenhuma execução chegou
à descrição (issue #110).
archive_worker_settings_revisions é um ledger de revisão no mesmo espírito: a decisão e a escrita
são fatos diferentes, e toda mudança de configuração deixa uma linha before/after.
Colunas geradas: uma definição, no banco¶
Duas colunas são calculadas pelo PostgreSQL e nunca escritas pelo código da aplicação.
| Coluna | Tabela(s) | Definição |
|---|---|---|
search_vector |
archive_documents |
setweight(to_tsvector(<dicionário>, public.immutable_unaccent(colunas de título)), 'A') concatenado com o corpo no peso 'B' |
error_fingerprint |
archive_worker_runs (sobre error), archive_api_errors (sobre message) |
public.archive_error_fingerprint(<coluna>) |
search_vector é contra o que a busca lexical roda, com o índice GIN
ix_archive_documents_search_vector. O dicionário vem do perfil de idioma
(get_language().fts_dictionary), e immutable_unaccent o torna insensível a acento, então gaucho
encontra Gaúcho. Como a coluna é armazenada, mudar ACERVO_LANGUAGE é uma migration que a
reconstrói, não um reboot — e alembic check relata exatamente esse drift em vez de deixar o modelo
e a expressão armazenada discordarem em silêncio.
archive_error_fingerprint(text) é IMMUTABLE e reduz uma mensagem de erro à sua causa raiz: a
primeira linha, em minúsculas, com uuids, hashes, números e caminhos substituídos por placeholders.
Ela é criada pela migration fe7fcab37207 e espelhada em testing/conftest.py, porque o schema
de teste é construído por Base.metadata.create_all e a função precisa existir antes de create_all
emitir a coluna gerada que a chama — exatamente como immutable_unaccent. O prefixo public. faz
parte da expressão porque é assim que o PostgreSQL a reflete de volta.
A razão de as duas serem geradas e não escritas pela aplicação é que uma coluna gerada preenche as
linhas existentes com a mesma expressão que as novas recebem. Existe exatamente uma definição, ela
não pode divergir do código que escreve a mensagem, e o texto do erro é gravado com a sua classe de
exceção (KeyError: 'nome') justamente para que a impressão digital distinga duas falhas.
Propostas que sobrevivem ao que nomeiam¶
| Tabela | Para que serve | Colunas principais |
|---|---|---|
archive_tag_merge_proposals |
Um agrupamento de tags que a rotina propõe unificar, e a decisão humana sobre ele. | proposal_id, fingerprint (único), canonical_id, canonical_name, members (JSONB), reason, review_flags, total_documents, status, decided_by, decided_by_user_id, decided_at, decision_note |
archive_hierarchy_node_plans |
Um degrau que os códigos de referência implicam, e a decisão do arquivista sobre ele. | veja "O arranjo" acima |
Uma proposta é evidência mais decisão, nunca uma escrita. A escada de status é
SUGGESTED → APPROVED/REJECTED → APPLIED: um veredicto humano é uma intenção durável que a execução
de sugestão nunca sobrescreve (fingerprint torna a sugestão idempotente), e aplicar o agrupamento
grava APPLIED no mesmo savepoint que absorve as tags. decided_by/decided_at guardam o
veredicto; o ledger de merge guarda o quando.
canonical_id e members são snapshots sem chave estrangeira, de propósito: aplicar a proposta
apaga aquelas tags, e a proposta precisa sobreviver como o registro do que foi decidido. O
agrupamento é identificado pelas suas grafias, não pelos seus ids. Como os membros podem morrer, a
leitura calcula members_alive, canonical_alive e applicable (canônica viva e mais de um
membro vivo) em uma consulta por página; applicable é a definição única de "ainda há trabalho".
Catálogos se aposentam, não se apagam¶
Os três catálogos que o arquivista estende sem deploy são archive_description_levels (a escada de
níveis), archive_macro_categories (as categorias de assunto) e archive_typologies (as tipologias
documentais — a forma diplomática: ata, ofício, planta). O vocabulário do acervo acrescenta
archive_arrangement_vocabulary e archive_collection_terms. Todos são tabelas em vez de enums para
que um nome possa mudar sem uma release.
Nenhum deles apaga. As chaves estrangeiras são SET NULL (archive_documents.level_id,
archive_documents.typology_id, archive_tags.macro_category_id), então remover uma linha
desclassificaria toda descrição que aponta para ela e ainda apagaria o registro de que um dia ela
existiu. Uma linha se aposenta com is_active=false — archive_tags é a exceção: uma tag não tem
essa coluna e sai pelos ledgers de merge e de expurgo de stopwords — e essa é também a única alavanca
que alcança o classificador: aposentar uma tipologia a remove dos rótulos candidatos sem tocar em
nenhuma descrição classificada, então a tela do catálogo continua mostrando o peso das aposentadas.
Duas regras sustentam o catálogo de tipologias: get_active_typologies() projeta apenas
(typology_id, name) e filtra is_active — o nome puro é o rótulo, porque acrescentar
context_description faz o modelo perder a inferência conforme o rótulo cresce — e um arquivista
escolhendo uma tipologia faz uma afirmação que o catálogo precisa responder, então um id desconhecido
é recusado com 422 em vez de gravado como NULL.
O idioma é código, não dado: core/language/ guarda um perfil congelado por idioma (stopwords,
gramática de datas, padrões de rua e placeholder, regras de plural, o dicionário de busca textual, o
modelo spaCy) e ACERVO_LANGUAGE o seleciona. O vocabulário do acervo é linha justamente porque o
acervo de outra instituição carrega outros nomes. Veja o ADR 0008.
A superfície pública: uma partição exata¶
A superfície de difusão reutiliza DocumentService e difere apenas na projeção. A visão de leitura
interna é DocumentSummary; a pública é PublicDocumentSummary, construída campo a campo e nunca
com model_validate sobre o DTO interno — validá-lo tornaria público automaticamente todo campo que a
visão interna ganhasse.
Dois frozensets em api/schemas/public.py tornam o padrão privado:
NOT_PUBLIC_FIELDS— campos deDocumentSummarydeliberadamente não publicados. Contémreview_status,is_anomaly,anomaly_reasons,archivist_notes,provenance,suggested_final_title,rank,path,parent_id,level_id,typology_id,is_published,admin_bio_history,admin_archival_historyeaccess_conditions.NOT_PUBLIC_FACETS— atualmente apenasanomaly_reason.
Junto com os campos e facetas publicados, eles precisam permanecer uma partição exata da leitura interna: todo campo ou é publicado ou está listado, e nenhum é as duas coisas. Um teste falha quando um novo campo ou faceta fica sem classificação, então o padrão para o que a visão interna ganhar a seguir é privado.
Mais duas regras completam a superfície. published_only é definido no servidor e não é parâmetro
de cliente, então nenhuma query string pode pedir um registro não publicado. Um id não publicado
responde 404, não 403: um status distinto confirmaria que a descrição existe. E published_only
é um filtro que nunca é excluído da contagem de facetas — nenhuma faceta pode levantar o portão da
difusão.
A trilha de revisão da curadoria humana¶
archive_document_revisions é onde as edições de uma pessoa viram evidência. PATCH
/api/v1/documents/{id} grava uma linha com o antes/depois de cada campo que realmente mudou, em
changes (JSONB), e marca o documento como HUMAN_APPROVED. Vincular e desvincular tags e entidades
grava o mesmo tipo de linha, guardando a lista inteira de nomes de cada lado em vez de um diff de
ids; uma chamada repetida que não muda nada não grava revisão.
A autoria são duas colunas, nunca uma:
| Coluna | O que guarda | Ao apagar a conta |
|---|---|---|
changed_by |
O nome que o histórico imprime — um retrato de quem a pessoa era. | Mantido; renomear alguém não reescreve o que ela decidiu. |
changed_by_user_id |
A conta por trás do nome, como chave estrangeira. | SET NULL; a decisão sobrevive com o nome do seu autor. |
O mesmo par aparece onde quer que uma pessoa decida algo: archive_document_deletions (deleted_by /
deleted_by_user_id), archive_taxonomy_merge_log (changed_by, undone_by),
archive_stopword_purge_log (changed_by, undone_by), archive_ner_purge_log (changed_by,
undone_by), archive_entity_merge_log (changed_by, undone_by),
archive_conflict_resolution_log (decided_by, undone_by), archive_hierarchy_node_plans e
archive_tag_merge_proposals (decided_by), archive_cleaning_rules e
domain_text_templates (created_by), archive_hierarchy_materialisation_log (changed_by),
archive_worker_runs (requested_by) e archive_worker_settings_revisions (changed_by). O autor é
gravado por author_columns/assign_author a partir da sessão, nunca tomado da requisição.