ADR 0023 — Fechamento da Fase 4b (sessão 2): catálogo que funciona, dedup que distingue quem negou, e fila que sobrevive à rodada
- Status: aceito
- Data: 2026-07-26
- Fase: 4b (fechamento da sessão 2 — a Anamnese em si é o ADR 0016)
Contexto
O ADR 0016 entregou a Anamnese: scheduler por projeto, proficiency_profiles
com evidência, catálogo derivado do module_map, instruction_patch como
proposed_action com diff LCS, versionamento append-only com rollback pra
frente, fila fechando o loop, e as duas seções de UI. Nada disso foi desfeito.
Faltava o passo que a sessão 1 recebeu no ADR 0022: rodar o critério de aceite
e fechar o que só aparece rodando. A auditoria dos três apps achou ~28
desvios — sete quebravam o critério, um item do enunciado nunca foi
implementado, e o critério nunca tinha sido executado (não havia
demo:anamnese, nem forma de rodar uma rodada sob demanda).
Decisões
1. O catálogo tem que tokenizar stack composta — era isto que travava tudo
deriveCatalog punha a stack do module_map inteira como UMA competência.
Só que ModuleMapModule.stack é UMA string de texto livre escrita pelo
Arquiteto (via LLM), e na prática lista várias tecnologias. Com
stack: "NestJS + Drizzle + Postgres", o catálogo ganhava a competência
"nestjs + drizzle + postgres" e a emissão natural ("nestjs") caía fora —
e como validateProficiencyBatch rejeita o LOTE INTEIRO, a rodada não
gravava perfil nenhum.
A consequência era pior que "um perfil a menos": só emit_proficiency dá halt
no ToolLoop, então a rodada nunca concluía, nunca gravava anamnese_runs, e a
janela era reprocessada indefinidamente. A Anamnese não funcionava em nenhum
projeto realista. O teste existente só exercitava o caso idealizado de token
único (deriveCatalog(['NestJS'])), que é justamente o que um humano
escreveria e um LLM não.
deriveCatalog passou a quebrar em +, ,, / e &, adicionando cada token
E a frase inteira (quem escreveu "Node.js" — um token com ponto — não pode
deixar de valer). Token de 1 caractere é descartado: alargar o catálogo com
ruído é exatamente o que o guarda-corpo não pode fazer, e há teste explícito de
que tokenizar não afrouxa a rejeição dos atributos sensíveis.
Dois defeitos vizinhos, do mesmo caminho de escrita:
- a competência era gravada CRUA, e o unique é
(project, user, competency)—"NestJS"e"nestjs "criavam duas linhas pra mesma coisa. Agora grava normalizada. - duas entradas pro mesmo
(userId, competency)no lote batiam emON CONFLICT DO UPDATE command cannot affect row a second time(o upsert é um comando só), virando 500 opaco em vez de tool-result corrigível. Agora é rejeição com mensagem que diz o que fazer.
2. Negação de POLÍTICA não é negação do usuário
"negação registra para não repropor igual" fala da decisão do usuário. Mas
o dedup filtrava só status === 'denied', e ProposeActionUseCase grava esse
mesmo status sem decisor quando o decide recusa por papel abaixo de
maintainer ou por permissions.json. Resultado: um patch barrado por
política ficava condenado pra sempre — o humano nunca viu o diff, e nem
corrigindo o papel dava pra propor de novo.
Passou a exigir decidedBy !== null. É uma linha, e é a diferença entre "o
usuário disse não" e "o sistema não deixou nem mostrar".
3. Consumo da fila é consequência do patch existir
emit_proficiency mandava consumedQueueIds: ctx.queued_ids incondicionalmente
junto dos perfis. Uma rodada que lia a hipótese aceita e não propunha patch
queimava a entrada da fila, e nada re-enfileirava: o critério "aceito uma
hipótese e vejo o patch seguinte referenciá-la" falhava em silêncio e não se
recuperava nunca.
Invertemos a responsabilidade: o engine não mexe mais em id de fila, e
ProposeInstructionPatchUseCase marca a entrada consumida quando o patch que
a referencia NASCE, na mesma transação. Uma hipótese lida e não usada continua
pendente pra próxima rodada.
Por que consumir na PROPOSTA e não na aprovação: a hipótese cumpriu seu papel quando virou um patch que o humano pode avaliar. Se o usuário negar o patch, quem impede a repetição é o dedup (decisão 2) — re-enfileirar levaria a propor o mesmo patch pra sempre.
Consequência de contrato: consumedQueueIds saiu do DTO interno, e
markConsumed(ids) virou markConsumedByHypothesis(projectId, hypothesisId).
O projectId no filtro fecha de passagem um furo cross-tenant: a versão antiga
era inArray(id, ids) sem escopo, então uma chamada podia marcar consumida a
fila de outro projeto.
4. hypothesisId é validado nas duas pontas
Nem o engine (contra as hipóteses da rodada) nem a api (contra o projeto)
validavam o id. Um id alucinado atravessava até
agent_instruction_versions.source_hypothesis_id e a rastreabilidade
hipótese→patch→versão apontava pra nada.
O engine rejeita hypothesisId fora de ctx.queued_hypothesis_ids, com a
mensagem voltando pro modelo corrigir no turno seguinte (mesmo idioma da
rejeição de evidência do Psicólogo); a api revalida que a hipótese existe e é
do projeto. Note que o ctx passou a carregar ids de HIPÓTESE, não de linha de
fila — o que o prompt oferece ao modelo e o que o patch precisa carregar são a
mesma coisa.
5. "comandos que aprova/nega" entra na janela — revertendo o deferimento do 0016
O item 1 do enunciado lista quatro sinais: linguagem, correções nos agentes,
comandos que aprova/nega, e nível das perguntas. Os três primeiros estão no
event log; o quarto vive em proposed_actions.decided_at, e o ADR 0016:170-174
deferiu a leitura ("evolução natural do contexto"). Um de quatro sinais fora,
por desenho.
Novo listDecidedInWindow(projectId, from, to) — só decisões com decisor
humano, na mesma janela que o engine usa pro log — alimenta um campo
decisions[] no contexto, e o prompt ganhou seção própria com o
rejectionReason. O motivo de uma negação é o sinal mais rico da janela:
diz o que a pessoa achou errado, com as palavras dela ("nunca use push
--force, gere migration" vale mais que dez mensagens de chat).
Triage.should_run? passou a contar decisões junto dos eventos: uma janela em
que o usuário só aprovou e negou ações É material, e era descartada como vazia.
6. Evidência de perfil é de escopo de PROJETO — a UI precisava resolver a sessão
O chip de evidência navegava sempre pra useLatestSession(projectId). Mas a
janela da Anamnese atravessa várias sessões (ADR 0016 decisão 10), então
qualquer evidência de sessão antiga caía em "evento não encontrado nesta
sessão". É o MESMO defeito que o ADR 0022 decisão 1 fechou pro Psicólogo,
repetido aqui — e ProficiencyProfile não tem sessionId pra corrigir no
cliente.
Novo GET /projects/:projectId/events/:eventId sobre GetProjectEventUseCase:
diferente do endpoint por sessão da sessão 1, aqui a sessão é a resposta,
não a validação. O chip resolve e então navega. O rótulo do evento fixado no
SessionPage deixou de dizer "citado pela hipótese" — agora chega de duas
origens.
7. Privacidade: cada um vê o seu; quem administra vê o time
GET /proficiency era viewer e devolvia o perfil de TODOS os membros
(competência, nível, os porquês, evidências). Pior: o delete exigia
developer, então um viewer era perfilado e tomava 403 pra apagar o
próprio perfil — furando o "todo o perfil visível e apagável pelo usuário".
Perfil de competência é dado sobre a pessoa, então o default é ela ver o dela:
owner/maintainer recebem a visão agregada (útil pra alocar trabalho),
qualquer outro papel recebe só o próprio, pelo listByUser que existia e era
código morto. Delete e opt-in passaram a exigir só viewer — ser membro.
Descartamos "ninguém vê o de ninguém": deixaria a tela vazia pra quem
administra, sem ganho real de privacidade dentro de um projeto que a pessoa
escolheu integrar.
Dois furos menores do mesmo guarda-corpo: a evidência não era escopada por
projeto (findById sem checar projeto, enquanto a mensagem prometia "deste
projeto"), e delete + opt-out eram dois awaits soltos — um crash entre eles
deixava o perfil apagado e re-derivável, ou seja o apagar teria sido cosmético
do mesmo jeito. Agora é uma transação.
8. Rodada sob demanda, porque 15 minutos não é testável
A única forma de uma rodada acontecer era o tick do scheduler. POST /projects/:projectId/anamnese/run (engine + api, maintainer pela mesma razão
da reanálise do Psicólogo: roda o ToolLoop e gasta orçamento) fecha a
assimetria e é o que torna o critério de aceite executável. Projeto sem sessão
responde 409 — não há log pra analisar nem onde narrar, igual ao caminho
periódico.
9. Paridade com o que a sessão 1 endureceu
A Anamnese tinha, um por um, os mesmos defeitos que o ADR 0022 fechou no Psicólogo:
performdevolvia:okna falha de contexto, com comentário dizendo que deixava o Oban retentar —:okmarcacompleted, então a rodada do projeto sumia em silêncio emax_attempts: 3era peso morto.reason_for({:ok, ctx})ignoravactx.last_error. Era a única dos quatro call sites (QA, Dev, Psicólogo, Anamnese) fora do padrão: provider caído virava "encerrou sem emitir perfis".- a janela ia numa mensagem
:pinned(que oContextManagernão pode compactar, de propósito — os event ids da evidência têm que sobreviver) com 500 eventos einspect(payload)sem truncagem, contra umcontext_windowfixo. Ganhou teto de eventos por config, payload truncado, e nota de omissão VISÍVEL pro modelo (ele só pode citar ids que vê). De passagem, ocreated_at— que era coletado e descartado — entrou na linha do evento: "nível das perguntas" é análise de tendência, e tendência precisa de tempo. - todos os tetos eram atributos de módulo. Foram pro
runtime.exse pro compose com os valores atuais como default. - o
ActionPipelineestava registrado em:pre_tool_usesendo no-op permanente (nenhuma tool da Anamnese éterminal/write_file).
10. Um diff, uma aparência
--diff-add/--diff-del são especificados em design/COMPONENTS.md:127 e
não existiam em design/tokens.css. O ApprovalCard usava
var(--diff-add, #0e2e24) e portanto sempre caía no hex hard-coded; o
histórico de versões, escrito depois, inventou cor de TEXTO em vez de fundo. O
mesmo patch tinha duas aparências dependendo de onde você olhasse. Os tokens
foram definidos nos dois temas (o par do spec é calibrado pro escuro) e os dois
lugares passaram a usar o mesmo idioma.
Também: apagar o perfil ganhou confirmação via o ui/Modal que já existia (é
irreversível e um clique cru era demais), rollback e opt-in ganharam estado de
pending (duplo clique criava duas versões), opt-in passou a invalidar a query,
e o card de patch renderiza todos os arquivos — não só files[0], enquanto o
branch de git_commit ao lado sempre loopou.
Verificação executada
pnpm --filter api demo:anamnese — o script separa explicitamente o que é
determinístico (catálogo tokenizado, guarda-corpo, fila só consumida com patch,
patch→aprovação→versão com sourceHypothesisId→rollback que volta o conteúdo
criando outra versão, dedup por decisão humana, opt-out impedindo
re-derivação) do que depende do modelo (a rodada gravar perfil com evidência
resolvível e propor o patch). Sai com código 1 listando o que falhou.
Suites: engine 250, api 500, web 105 — todas verdes. Os testes que faltavam
incluem os primeiros de web da Anamnese (a sessão 1 ganhou os dela no ADR 0022;
esta não tinha nenhum) e dois que guardavam garantias sem cobertura: o teto de
"instruction_patch nunca é auto-aprovável" no decide.ts — mesma classe da
trava de merge, que tem quatro testes — e o ExecuteInstructionPatchUseCase,
único executor sem spec, passado como undefined as never nos testes vizinhos.
A janela era truncada pra segundo — e a rodada era pulada em silêncio
Isto só apareceu rodando o demo:anamnese de verdade, e era a causa REAL do
"perfil não vem" que eu havia atribuído a flakiness do 7B local. token_usage
pro ator anamnese estava em ZERO: o modelo nunca era chamado.
session_events.created_at estava declarado :utc_datetime no schema Ecto
(precisão de SEGUNDO) contra uma coluna timestamptz(6). O Ecto trunca o
parâmetro na comparação, então created_at < window_to virava
< 15:39:32 em vez de < 15:39:32.931 e descartava tudo que aconteceu no
segundo corrente. Medido no banco: 0 eventos na janela truncada contra 9 sem
truncar, com o primeiro evento em 15:39:32.363.
Como o script faz tudo em menos de um segundo, a janela ficava vazia SEMPRE. Em produção o tick de 15 min quase sempre escapa (os eventos já são de segundos anteriores), e é por isso que passou pela auditoria — que chegou a registrar isso como "menor, hedged". Não era menor.
Duas mudanças:
:utc_datetime_usecno campo, alinhando com a coluna. O teste de regressão (event_window_test.exs— a janela por projeto não tinha nenhum) foi verificado FALHANDO sem a correção: um teste que passaria de qualquer jeito não provaria nada.- A rodada pulada passou a deixar rastro (
Logger.infocom as contagens e o limiar). Deliberadamente NÃO é evento no log: um tick a cada 15 min por projeto viraria ruído. Mas o silêncio total foi exatamente o que escondeu isto — uma rodada que não faz nada e não diz nada é indiagnosticável.
Com a correção, o demo:anamnese passou INTEIRO: critério de aceite da Fase 4b (sessão 2) ATENDIDO.
A passada visual achou três coisas que teste nenhum pegaria
O demo:* prova dado; ele não prova que a tela mostra o dado. Abrindo a UI no
projeto-cobaia:
- O histórico de instruções era invisível pros agentes que existem. A
seção fazia fan-out sobre
AGENT_LIST(roster estático, comdev-backend/dev-frontend), mas a Fase 4a instancia um dev agent POR MÓDULO (dev-api,dev-web). Com 3 versões dedev-apino banco, a tela dizia "Nenhum agente teve a instrução alterada ainda" — ou seja o item 5 do enunciado (histórico com diff e rollback) estava morto justamente pros agentes que a Anamnese patcheia. NovoGET /projects/:projectId/instruction-versionsdevolve quem TEM histórico, e a UI parou de adivinhar slug. - As narrações de patch e rollback perdiam a versão.
payloadFieldsó devolviastring, etoVersion/restoredFromchegam como número — então o feed dizia "instrução de dev-api atualizada" (sem a versão) e "revertida para a v?". Passou a aceitar número. - "Sempre permitir" aparecia num
instruction_patch. O teto dodecide.tsforçarequire_approvalsempre, então gravar a regra empermissions.jsonnão muda nada: o botão prometia um efeito inexistente. Escondido para esse tipo.
Verificado na tela, além disso: nível/porquês/chips do perfil; chip de
evidência resolvendo a sessão CERTA (foi pra sessão que contém o evento, não
pra mais recente) com o evento fixado no topo do log; badge hipótese <id> na
versão e no card de aprovação; diff com os tokens novos nos dois lugares; e —
fechando o que o ADR 0022 afirmou sem verificar — a faixa de análises do
Psicólogo com custo divergindo de verdade (pesada US$ 0,0068 vs leve US$
0,0001) e um chip de evidência apontando pra um agent.response, o tipo que o
feed esconde, chegando no evento.
Escopo & assunções
Fora deste fechamento: sincronizar a união ActionType do web com os 12 tipos
do backend (débito pré-existente, ADR 0016); rebuild de prompt em GenServer
conversacional vivo; índice em session_events pra janela por projeto; edição
manual de instrução pela UI; índice em source_hypothesis_id e a consulta
reversa (hipótese → versões), que ninguém pede ainda.
A apply do patch continua rodando fora da transação que atualiza a ação
(ExecuteInstructionPatchUseCase): um crash exatamente entre as duas deixa a
instrução patchada com a ação em approved. Fica registrado porque é real,
mas o conserto pede repensar a fronteira transacional de TODOS os executores
(o de infra e o de git têm a mesma forma), e fazer isso só aqui criaria
inconsistência entre irmãos.