Diário — 26/09/2026
Presentes: Luiza (@LuizaMaluf)
Pauta: issue #1 — subir o Postgres com docker compose.
O que foi feito
docker-compose.yml com PostgreSQL 16.15 e pgAdmin 9.18.0. Volume nomeado pgdata, healthcheck com pg_isready, e o pgAdmin só sobe depois de o banco estar healthy. Teste de fumaça em tests/infra/test_compose.sh cobre os critérios de aceite 1 e 2: sobe, grava uma linha, faz down + up e confere que a linha continua lá.
O que foi medido
| Medida | Valor | Condição |
|---|---|---|
| Teste de fumaça completo, primeira vez (inclui download das imagens) | 44,6 s | Mac M-series, Docker Desktop 29.6.2 |
Teste de fumaça completo, imagens em cache (2 ciclos de up) | 8,5 s | idem |
up --wait → db healthy, volume vazio | 6,4 s | idem |
Tamanho da imagem postgres:16.15 | 663 MB | |
Tamanho da imagem adminer:5.4.2 (primeira versão, substituída) | 165 MB | |
Tamanho da imagem dpage/pgadmin4:9.18.0 | 805 MB | |
pgAdmin: up → responde HTTP 200 | ~8 s | idem |
| Teste em clone limpo, versão com adminer | passou | clone local da branch numa pasta nova, antes do push |
Teste em clone limpo do main no GitHub (PR #7, com pgAdmin) | passou; make setup em 6,4 s | git clone do GitHub + make setup + make test, após o merge |
Antes da correção: pgAdmin pelo IP da rede (192.168.68.x:5050) | HTTP 200 | main do PR #7, portas em 0.0.0.0 |
Antes da correção: psql -h 192.168.68.x com a senha do README | entrou | idem |
| Depois da correção: pgAdmin e Postgres pelo IP da rede | recusados | portas em 127.0.0.1 |
O que surpreendeu
Já havia um Postgres 17 do Homebrew no Mac escutando em 127.0.0.1:5432. Com ele ligado, o up falharia, ou o psql -h localhost conectaria no banco errado sem avisar. Resolvido parando o serviço (brew services stop postgresql@17), mas qualquer pessoa da squad com Postgres local cai nisso. Para conferir em qual banco você está: select version(); tem que dizer linux, não darwin.
O que foi decidido
- Porta configurável (
${POSTGRES_PORT:-5432}): o padrão continua 5432 para a máquina limpa, e quem tem conflito troca só no próprio.env. - pgAdmin em vez de adminer (revisto no mesmo dia). Começamos com adminer, mais leve. Na primeira tentativa de uso, o login falhou com
Connection refusedporque o campo servidor foi preenchido comlocalhost— dentro do container do adminer,localhosté ele mesmo, e o banco édb. Trocamos para pgAdmin em modo desktop (sem tela de login) comservers.jsonepgpassgerados a partir do.envviaconfigs:do Compose: abre já conectado, sem ninguém digitar host. Ganhamos EXPLAIN visual e gerência de roles. Perdemos leveza (805 MB contra 165 MB do adminer, ~8 s para responder) e o modo desktop não tem autenticação — só é aceitável com a porta presa em127.0.0.1(ver revisão abaixo). - Imagens com tag exata (
16.15,9.18.0) em vez de16/latest: o que a monitoria sobe é o mesmo que nós testamos. O custo é atualizar à mão. make setupcomo porta de entrada: um comando cria o.env(sem sobrescrever um existente) e sobe tudo. OMakefilesó embrulhadocker compose, então quem não temmakeroda os comandos equivalentes do README.- Sem script de init de esquema aqui: o esquema e as migrações são contrato da #2.
Revisão depois do merge (PR #7)
A revisão de código do PR #7, feita depois do merge, achou dois problemas de segurança introduzidos por nós:
- O pgAdmin sem login estava publicado em
0.0.0.0:5050. O diário dizia que a porta era local, e não era: qualquer máquina da mesma rede abria o pgAdmin já conectado ao banco, com acesso total. Medido: HTTP 200 no pgAdmin e login nopsqlpelo IP da rede. - O Postgres também estava em
0.0.0.0:5432, com a senhaconselhosque está no README público.
Corrigido publicando as duas portas só em 127.0.0.1. O teste de fumaça agora falha se alguma porta voltar a escutar fora de 127.0.0.1 — a regra deixa de depender de alguém lembrar.
Outros achados corrigidos na mesma revisão:
- O teste fazia
source .env, e uma senha do INLABS com espaço ou;quebrava o bash (ou executava comando). Agora as credenciais são lidas dentro do container. - O
servers.jsondo pgAdmin só é importado na primeira subida do volume. MudarPOSTGRES_USERouPOSTGRES_DBdepois deixava o pgAdmin pedindo senha. Criadomake pgadmin-reset. - A mensagem de falha de persistência do teste nunca aparecia: o script abortava antes, no
SELECTde uma tabela inexistente.
Lição: "local" precisa ser verificado, não declarado. Afirmamos no diário algo que um curl no IP da rede desmentia.
Revisão da #2 (esquema do OLTP)
Revisão da branch feature/issue-2-oltp-schema (do Moura, até 2b621c2) e correções aplicadas na própria branch.
O que foi medido
| Caso | Antes | Depois |
|---|---|---|
| Teste do esquema reescrito (16 casos), banco local | 16 falhas: 0 tabelas no banco | 16 ok |
| Idem, banco zerado (worktree isolada) | 13 falhas | 16 ok |
| Passo "comentários" do teste antigo | contava 5.266 entradas do pg_description, só 14 nossas | conta só o schema public |
Ato de 2003 da planilha (sem id_dou) | recusado | aceito como origem = 'PLANILHA' |
| Pesquisadora reclassifica DEF → GEST | duplicate key | 2 linhas; vigente = GEST |
make migrate pela 2ª vez | — | não reaplica nada |
make setup em clone limpo (inclui 1ª subida do pgAdmin) | — | 38,2 s |
| Linhas deixadas pelo teste no banco | — | 0 |
O que surpreendeu
- O banco local da squad não tinha nenhuma tabela. O esquema era montado em
docker-entrypoint-initdb.d, que só roda com volume vazio. Como todo mundo já tinha o volume da #1, a branch subia sem tabela e sem erro. Um003_futuro também nunca chegaria. - As definições de
AUTO,GESTeIPno seed não batiam com a tipologia da pesquisadora.AUTOvirou "Autorização", quando é autorregulação. O teste antigo só contava as siglas, então não pegava. - A branch tinha sido criada antes do PR #8 e ainda publicava as portas em
0.0.0.0. Trazer omainresolveu sem conflito.
O que foi decidido (a confirmar no ADR da E1, #5)
- D1 — classificação insert-only. Reclassificar é uma nova linha, a view
classificacao_vigentedevolve a última de cada revisor, e um trigger bloqueiaUPDATE/DELETE. Ganhamos o histórico do julgamento humano, que é o dado que nasce aqui, e a divergência humano × pipeline fica mensurável ao longo do tempo. Perdemos a simplicidade doUPDATE: corrigir exige nova linha, e toda leitura passa pela view. - D2 — classificamos a versão do ato. Uma retificação nasce sem classificação e precisa ser reclassificada.
- D3 —
ref_legal+ órgão como chave dos atos da planilha, a confirmar com quem pegar a #3. - Migrações por
make migratecomschema_migrations, e não porinitdb. Arquivo mergeado não se edita. ON DELETE RESTRICTemclassificacao.ato_id: apagar um ato não pode apagar julgamento humano.
Issue #3 (normalizar as 5 abas da pesquisadora)
Presentes: Bruno (@BrunoBReis), Márcio Henrique
Branch feature/issue-3-normalizar-planilhas. Antes de escrever código, medimos qual ferramenta faz a extração e como ela roda.
O que foi medido
| Medida | Valor | Como |
|---|---|---|
| Linhas com dado nas 5 abas | 215 (CNAS 53, CONAMA 98, CONCIDADES 28, CONDRAF 24, CNPIR 12) | três leitores independentes: XML do .xlsx lido direto, openpyxl e pandas.read_excel |
Linhas formatadas nas 5 abas (max_row − 1 do openpyxl) | 2.638 (366 + 99 + 848 + 951 + 374) | openpyxl 3.1.5 |
Planilha de data/raw × planilha do repositório antigo (commit 5e6b516, 17/09) | idênticas byte a byte (sha256 635b3f0a…) | cmp e sha256sum |
| Anos dos atos | 2003–2020 (CNAS 2003–2004, CONAMA 2003–2006, CONCIDADES 2004–2005, CONDRAF 2003–2009, CNPIR 2005–2020) | coluna de data de cada aba |
ID_VERSAO repetido dentro de uma aba | 0 nas 5 abas | contagem |
O que surpreendeu
- A carga inicial é 215 atos, não 2.638. O número de 25/09 contou as linhas que têm formatação de célula, e a maioria delas está vazia. O erro se espalhou para o README, os ADRs 0001 e 0002 e a issue #3, e foi corrigido nos arquivos do repositório (o diário de 25/09 ganhou uma errata). As medições dos ADRs com n = 2.638 continuam valendo como volume sintético: são ~12× a carga real. Pesa na E1, que pede volume relevante ("não bastam 100 linhas").
- O Excel corrompeu o
DIS_LEG (ATO/ANO)do CNPIR. "1/2005" foi lido como data e virou 01/01/2005, em 11 das 12 linhas. Dá para recuperar: o mês é o número do ato, e o resultado bate com o texto doTEMAnas 11. - A chave D3 da #2 (
ref_legal+ órgão) não se sustenta. O CNAS não tem coluna de referência legal. ODISP_LEGdo CONAMA traz só o tipo ("Resolução", "Moção"), sem número. No CONDRAF, "Resolução Nº 61" aparece em dois atos diferentes. - Tipologia
99em 6 linhas, fora das 5 categorias. O cabeçalho do CNAS dizTIPOL_DECIS (M e J), então quem classificou pode não ter sido a pesquisadora. O cabeçalho do CONAMA está na linha 2, não na 1.
Qual ferramenta lê a planilha
Escrevemos três leitores com o mesmo contrato de saída: aba, linha do Excel, id, data, referência legal, tipologia e texto. Cada um foi medido contra as armadilhas acima e contra duas mutações da planilha: uma data virando o texto s/d e a linha 10 do CNAS esvaziada. Os resultados brutos estão em docs/diario/medicoes/2026-09-26-extracao/resultados.json; o código do benchmark e dos três leitores ficou no commit e4f87eb e saiu do repositório no PR, porque os leitores descartados não seriam mantidos. Condições: Linux x86_64 (AMD Ryzen 7 2700), Docker 29.7.2, mediana de 5 execuções, três rodadas com resultados estáveis. O polars saiu da lista antes da medição.
| Python stdlib | pandas 3.0.6 + openpyxl 3.1.5 | DuckDB 1.5.5 | |
|---|---|---|---|
Armadilhas e mutações (215 atos, CONAMA na linha 2, datas, CNPIR, 99, linha do Excel, determinismo; s/d falha e para; linha vazia não some) | todas | todas | todas |
| Os 215 registros, campo a campo | iguais nos três | iguais nos três | iguais nos três |
| Linhas de código | 61 | 34 | 35 |
Imagem a mais sobre python:3.12-slim / tempo de build | 0 MB / 0,7 s | 45 MB / 23 s | 30 MB / 10 s |
| Execução no container | 0,7 s | 1,7 s | 0,6 s |
| Chamada mais direta da ferramenta, sem contorno | não existe: toda conversão é escrita à mão | o max_row conta 2.638 linhas; o 1/2005 do CNPIR vira uma data que parece válida | para na primeira linha vazia: com a linha 10 esvaziada, devolve 8 dos 53 atos do CNAS, sem erro |
Escritos com cuidado, os três acertam. O que os separa é o comportamento padrão, que pega quem mexer no script depois, e o custo.
O que foi decidido
- A extração usa só a biblioteca padrão do Python (
zipfile+xml.etree) e roda num container do compose (python:3.12-slim).- O que ganhamos: nenhuma dependência e nada baixado da internet; toda conversão (data serial, CNPIR,
99) fica no nosso código e coberta por teste; a linha do Excel vem do próprio XML. - O que perdemos: quase o dobro de código (61 linhas, contra 34 e 35), e um leitor de
.xlsxque a squad passa a manter. Ele cobre só o que esta planilha usa: strings compartilhadas, números e datas no sistema 1900. Ele também não fala com o Postgres: quem grava no banco é opsqldo containerdb, como noscripts/migrate.sh. - Por que o container e não
uv runno host: Docker emakecontinuam sendo os únicos pré-requisitos do README.
- O que ganhamos: nenhuma dependência e nada baixado da internet; toda conversão (data serial, CNPIR,
- Não abrimos ADR para isso, a princípio. A leitura do
.xlsxroda uma vez, na carga inicial, e trocar de leitor é trocar um arquivo: é uma decisão reversível e isolada. Revisto na revisão do PR: a leitura, a chave e a regra da classificação ficaram registradas juntas no ADR 0003. A escolha entre container euvno host volta na #6, onde ela vira padrão para o resto do pipeline. - Chave dos atos da planilha: conselho +
ID_VERSAO, com a migraçãosql/003e um índice único. Substitui a D3.
A carga (make carga)
O pacote src/planilha/ usa só a biblioteca padrão e roda no serviço carga do compose, sem rede e com as montagens só de leitura. Ele escreve no stdout: o SQL, que o psql do container db executa (como no migrate.sh), o relatório em docs/carga/relatorio-planilha.md e o CSV normalizado em data/interim/. O make setup e o make reset passam a carregar a planilha.
O que foi decidido na implementação:
- Os 5 conselhos entram por migração (
sql/004), porque todo ato exigeorgao_ide a #4 ainda não saiu. Entram só com o nome vigente e a data do ato legal que os instituiu, conferida no planalto.gov.br. Hierarquia e aliases ficam para a #4, que reaproveita estes 5 órgãos. - Resumo em
ementa, texto emconteudo. OTEMAmuda de natureza entre as abas:- CNAS e CONDRAF: resumo, com ~100 caracteres de mediana;
- CONCIDADES: a parte "RESOLVE:", com o resumo à parte em
TEMA_RESUM; - CONAMA e CNPIR: texto transcrito, com ~1.900 caracteres de mediana e 41.941 no máximo.
- Tipologia
99vira uma sigla da tabelatipologia(sql/005, "Fora da tipologia"). São 6 atos: moções, uma menção honrosa e duas recomendações do ConCidades. O "99 MOÇÃO" do CNPIR entra como99, e a anotação fica no CSV. - Revisora de todas as classificações: a "Pesquisadora Principal" do seed, embora o cabeçalho do CNAS diga
(M e J). Fica em aberto na issue. - A classificação da planilha entra uma vez por ato e nunca sobrepõe a curadoria. Se a pesquisadora já classificou o ato, rodar a carga de novo não faz nada. Consequência aceita: se uma versão nova da planilha corrigir um rótulo, a correção não chega ao banco pela carga, só pela curadoria.
- Linhas com problema viram descarte, com o motivo no relatório. Se a estrutura da planilha mudar (aba ou coluna faltando, célula de erro, datas de 1904), a carga para. As colunas são achadas pelo texto do cabeçalho, não pela letra.
O que foi medido:
| Medida | Valor |
|---|---|
| Atos carregados / descartados / com classificação | 215 / 0 / 215 |
make setup num clone limpo da branch (imagens em cache) | 11,9 s, com "215 atos novos e 215 classificações novas" |
make test no clone limpo | 4 suítes passando: infra, esquema (20 casos, eram 16), leitura da planilha (21), carga (10) |
git status no clone depois de make setup e de make test | vazio: o relatório gerado do zero é idêntico ao versionado |
| Segunda carga seguida | 0 atos novos e 0 classificações novas; relatório com o mesmo sha256 |
| Testes da leitura com o código sabotado (5 defeitos plantados) | os 5 pegos |
Caso "a carga não sobrepõe a curadoria" com o NOT EXISTS removido | pega: a vigente vira AUTO e entrariam 215 classificações duplicadas (tudo desfeito) |
O que surpreendeu:
- As linhas abaixo do cabeçalho somam 2.637, não 2.638. O cabeçalho do CONAMA está na linha 2, e o
max_row− 1 de 25/09 contou esse cabeçalho como se fosse uma linha de dado. - A Lei 10.683/2003 renomeou o "Conselho Nacional de Desenvolvimento Urbano" para "Conselho das Cidades". É exatamente o caso que o
orgao_nomecom vigência existe para guardar; fica para a #4. - A planilha estava aberta no LibreOffice durante o trabalho (havia um arquivo
.~lockemdata/raw/). Salvar por lá mudaria o arquivo e o sha256 do relatório. Conferimos antes de versionar que o arquivo continuava idêntico ao do repositório antigo. - A planilha é versionada em
data/raw/. A professora tornou a planilha pública, e isso remove o passo manual de copiar o arquivo num clone limpo.
Issue #4 (identidade dos conselhos)
Branch feature/issue-4-identidade-conselhos, a partir da branch da #3, que já tem o leitor de .xlsx e os 5 conselhos do sql/004. Antes de qualquer código, escrevemos e validamos o ADR 0004: a identidade sai de uma chave normalizada mais decisões registradas, nunca de similaridade de texto.
O que foi medido
| Medida | Valor | Como |
|---|---|---|
| Linhas da aba "Conselhos mapeados no DOU" | 125 | leitor da #3 |
| Chaves distintas (caixa, acento, espaços, sigla no fim) | 95 (a normalização de 25/09 dava 96: faltava a sigla com hífen) | src/conselhos/identidade.py |
| Conselhos distintos | 82, e até 85 conforme as 3 ambiguidades; 2 linhas indefinidas | docs/carga/relatorio-conselhos.md |
| Órgãos com código SIORG | 68 de 82 | recorte do SIORG consultado em 26/09 |
| Similaridade do mesmo conselho renomeado × de dois conselhos diferentes | 0,889 ("Pessoa Idosa" × "do Idoso") × 0,887 ("Pessoa Idosa" × "LGBTQIA+") | docs/adr/medicoes/0004-identidade/bench.py |
| Partição da carga × gabarito do ADR | idênticas, linha a linha | teste test_particao_igual_ao_gabarito_do_adr |
make setup num diretório limpo (volume novo) | 13,9 s, com 77 órgãos novos, os 5 da #3 reaproveitados e 125 aliases | cópia dos arquivos versionados, sem .env |
make test no diretório limpo | 45,6 s, 4 suítes passando: esquema (27 casos), leitura da planilha (21), conselhos (27) e carga (15) | idem |
| Segunda carga seguida | 0 órgãos, nomes, aliases, atos e classificações novos; os dois relatórios com o mesmo sha256 | tests/db/test_carga.sh |
| Testes da identidade com o código sabotado (5 defeitos plantados) | os 5 pegos | cópia de src/ com um defeito por vez |
O que foi decidido na implementação
Cada ponto com opções apresentadas e escolhidas antes do código:
- As decisões ficam num CSV versionado (
data/referencia/conselhos-decisoes.csv), que a pesquisadora abre numa planilha. A carga confere cada linha contra a planilha: se a linha não existe, o nome não bate ou há vírgula sem aspas, ela para. - O SIORG entra por um recorte versionado (
data/referencia/siorg-conselhos.csv, uma linha por chave da planilha). Quem gera é omake siorg, o único comando com rede, e omake setupnão o chama: a carga continua sem rede e reproduzível. - A carga dos conselhos roda dentro do
make carga, antes dos atos, no mesmo serviçocarga. orgao_aliascomstatus(RESOLVIDO, INDEFINIDO, AMBIGUO): só o resolvido tem órgão, e o pendente exige motivo, garantido porCHECK.- Nome vigente: vem do SIORG quando a chave casa com uma unidade só; senão, da decisão "nome" do CSV. Uma decisão "nome" contra o SIORG para a carga.
orgao.codigo_siorgeorgao.siglacomo colunas. A sigla não é única, porque CNE e CNT se repetem.- Os 3 ambíguos não viram órgão: ficam em
orgao_aliassem órgão, esperando a resposta. - "do Idoso" e "da Mulher" entram como renomeação, com a data do primeiro ato conferido com o nome novo (Decreto 9.893/2019 e Decreto 11.351/2023), e o motivo diz que o ato não fala em "passa a denominar-se". O nome antigo fecha na véspera.
O que surpreendeu
- O CSV de decisões tinha uma vírgula sem aspas, e o
csv.DictReaderjoga a sobra numa coluna sem nome, sem erro. Os testes pegaram na primeira execução, e a carga agora recusa linha com número de colunas errado. - No primeiro teste em diretório limpo, o Compose reaproveitou o volume do projeto, porque o diretório tinha o mesmo nome. A carga respondeu "0 órgãos novos", o que parecia sucesso. Refeito num diretório com outro nome.
- Os 5 conselhos da #3 casam exatamente com o nome vigente do SIORG, então a carga os reaproveita sem decisão nenhuma, e eles só ganham código SIORG e sigla.
- O cruzamento com o
artCategorydo DOU, pedido na issue, fica para a #6: ainda não temos o DOU. Oorgao_aliasjá aceitafonte = 'DOU', e a chave é a mesma.
Nota sobre uso de IA
Plano, compose, teste de fumaça e textos de documentação desta issue foram produzidos com auxílio de Claude Code. As tags das imagens foram conferidas no Docker Hub, o conflito de porta foi detectado com lsof, e todas as medições acima foram executadas na máquina, não estimadas. Ver AI-USAGE.md.