feat(G1-04): gap audit specs 04/05 vs real code — spec 13 Apêndice B [gov-loop]

20 items audited (9 impl / 5 partial / 6 absent), file:line evidence;
INB-01 opened (supervisor read-only §10 uncovered, conflicts spec 13 §4).
Verified-by: gov-verifier PASS 2026-07-16T20:12-03:00
This commit is contained in:
Rafael Melgaço
2026-07-16 20:03:49 -03:00
parent f7a6b3c83d
commit cedf0ade73
3 changed files with 61 additions and 4 deletions
+40 -2
View File
@@ -150,5 +150,43 @@ entre orgs (pré-requisito de tudo) já é coberto por
## Apêndice B — Auditoria spec 04/05 vs código (G1-04)
_Preenchido por G1-04: item da spec → {implementado|parcial|ausente} → evidência
`arquivo:linha` → feature G* que cobre._
Auditoria mecânica em `gov/G1` (2026-07-16). Status: **implementado** = funciona
de ponta a ponta; **parcial** = existe mas incompleto (a evidência diz o que
falta); **ausente** = só spec. Itens parcial/ausente apontam a feature G* que os
cobre (`plan/features.json`); gap sem feature → proposta na inbox do loop.
Conferido também nas branches `vendaval/F2-19..22` (`git grep`): nenhum artefato
de governança implementado lá além das próprias specs — nada a anotar.
### B.1 — Spec 04 (Pipeline + Atendimento)
| Item da spec | Status | Evidência (arquivo:linha) | Cobre |
|---|---|---|---|
| Claim atômico §9.2 (AT-02): UPDATE condicional `assigned_to_user_id IS NULL` + 409 | implementado | `app/api/v1/conversations/[id]/claim/route.ts:59-78` (UPDATE condicional com optimistic lock `expected_assignee`), `:83-85` (0 linhas → 409 `state_conflict`); audit `conversation.claimed` `:89-96`; `emit_event` `:98-109` | — (código de erro é `state_conflict`, não `conversation_already_claimed` da spec §9.2 — desvio cosmético) |
| Claim UI §9.1/§9.3: botão "Eu cuido" + tratamento de 409 | implementado | botão "Assumir" em `components/inbox/ConversationHeader.tsx:55-68` (render se `isOpen`, `:36`); hook `hooks/inbox/useClaimConversation.ts:15-29` (409 → `showApiError` + `invalidateQueries` `:21-24`) | — (sem o Dialog de confirmação da §9.1 — claim é 1 clique; desvio de UX, não de mecânica) |
| Release (soltar conversa — par do claim, base do `reason=release` da spec 13 §3) | implementado | `app/api/v1/conversations/[id]/release/route.ts:47-53` (UPDATE limpa assignee, filtro `assigned_to_user_id = caller`), `:61` (409 se não é o dono); botão "Liberar" `components/inbox/ConversationHeader.tsx:70-78` | — |
| `<ReassignDialog>` (transferir para outro atendente — §3 estrutura de pastas, §14) | ausente | nenhum componente/endpoint de reassign: `components/kanban/`+`components/inbox/` não têm o arquivo (`ls components/inbox components/kanban`); único caminho de troca de dono é claim com `expected_assignee` (`claim/route.ts:69-76`), que é takeover pelo próprio caller, não atribuição a terceiro | **G3-01** (desc.: "habilita o ReassignDialog de verdade") |
| `<UnassignedQueueAlert>` §8.3 (alerta de contagem "N conversas sem responsável") | parcial | componente de alerta não existe; o que há: aba "Não atribuídos" no inbox `components/inbox/InboxFilters.tsx:17,92-94` mapeada para filtro `assigned_to: "unassigned", status: "open"` em `components/inbox/InboxLayout.tsx:22-23`; índice parcial da fila `supabase/baseline.sql:2380` (`idx_conversations_open_unassigned`) | **G5-03** (fila visível com posição + notificação); **G4-02** (visões minhas/fila/todas) |
| Round-robin de não-atribuídas (AT-03, §8.3 "worker server-side") | parcial | round-robin existe SÓ no caminho do handoff MCP: `lib/mcp/tools/handoff.ts:37` (`pickRoundRobinAssignee`), invocado em `:106`; não há worker de roteamento geral consumindo `event_log` (handlers registrados: `lib/event-log/register-handlers.ts:20-25` — nenhum de routing) | **G5-02** (desc.: "AT-03 de verdade") |
| `<AttendantStatusToggle>` §8.1 (online/busy/offline + pinned) | ausente | nenhum componente/hook de presença de atendente em `components/`/`hooks/` (`grep -r "AttendantStatusToggle\|useAgentStatus\|useHeartbeat"` → 0); sem tabela de disponibilidade no schema (`grep -c attendant_availability supabase/baseline.sql` → 0) | **G5-01** (desc.: "persiste o AttendantStatusToggle da spec 04 §8"); painel em **G5-04** |
| Heartbeat 60s + auto-offline 15min §8.2 (AT-08) + worker server-side 90s | ausente | mesma evidência da linha anterior — não há `hooks/presence/`, endpoint de heartbeat nem cron (`app/api/v1/cron/` tem só agent-dispatcher, lgpd-sla-watcher, kb-conversations-batch, storage-redaction) | **G5-01** (schema/API de disponibilidade) |
| Supervisor read-only §10 (AT-04): composer bloqueado p/ manager não-dono + 403 server-side + audit `conversation.observed_by_supervisor` | ausente | UI: composer só desabilita por status/bloqueio — `components/inbox/Composer.tsx:31` (`disabled \|\| blockedReason \|\| isPending`), `components/inbox/InboxLayout.tsx:130` (`disabled={status === "closed"}`) — nenhum conceito de supervisor; API: POST de mensagens não checa assignee (único 403 é `no_active_org`, `app/api/v1/conversations/[id]/messages/route.ts:38`); audit action inexistente (`grep -r observed_by_supervisor app lib` → 0) | **nenhuma feature G\*** — proposta registrada na inbox do loop (**INB-01**). Nota: a matriz §4 desta spec dá `org:read+write` a manager, o que conflita com o read-only da spec 04 §10 — decisão de produto |
| Bulk actions §6.5 (AT-06): move/assign/tag + limite 50 | parcial | API completa: `app/api/v1/leads/bulk/route.ts:21` (`MAX_BULK = 50`), `:49` (422 `bulk_too_large`), case `assign` aceita qualquer `owner_user_id` uuid `:90-104` + `lib/schemas/leads.ts:111-113`; UI: `components/kanban/BulkActionBar.tsx:55-101` (runMove/runAssign/runTagAdd/runDelete), mas "Atribuir a…" só oferece "Eu"/"Remover responsável" `:134-136` — sem seleção de outro atendente | **G3-04** (atribuição em massa de ponta a ponta — falta só o seletor de atendente na UI) |
| Dashboard lite §13 (4 cards: abertas por atendente, 1ª resposta, pendentes, resolução) | ausente | `/dashboard/atendimento` não existe; `app/app/page.tsx:3` redireciona direto pra `/app/inbox`; nenhum card de métrica de atendimento em `app/app/` | **G4-04** (métricas por responsável + performance individual) |
| Dono do lead visível no card do kanban (§6.2 `card.owner`) + filtro por dono (§6.4) | parcial | card mostra avatar com iniciais derivadas do **id** (não nome): `components/kanban/KanbanCard.tsx:31-33,96-99`; filtro por dono existe incl. "unassigned": `components/kanban/FilterBar.tsx:53,84` + `lib/kanban/filters.ts:18` | **G3-03** (nome/badge "sem responsável" + coluna na lista) |
### B.2 — Spec 05 (Handoff IA) e baseline
| Item da spec | Status | Evidência (arquivo:linha) | Cobre |
|---|---|---|---|
| Gatilhos de handoff §7.1-§7.4 (G1 pedido explícito, G2 sentiment, G3 incerteza, G4 jurídico/estágio) | implementado | predicados `lib/ai/handoff/triggers.ts:22-40` (checkG1/checkG4Legal/checkG3) e `:43-78` (checkG4Stage via `crm_stages.requires_human`); triagem síncrona pré-bot `workers/ai-response-worker.ts:96-121` (G1/G4), pós-resposta `:143-153` (G3); G2 via evento `ai.sentiment_alert` → `workers/ai-handoff-from-sentiment.handler.ts:20,97` | — |
| Ação de handoff §7.5 (`triggerHandoff`: pending + silêncio + activity + event_log + broadcast + audit) | implementado | `lib/ai/handoff/orchestrator.ts:51` (entrada), `:92-100` (UPDATE `status='pending'`, `bot_silenced_until='infinity'`, `last_handoff_*`), `:117` (activity), `:139` (emit_event `ai.handoff_triggered`), `:180` (audit); janela de idempotência 5s `:47` | — |
| Política de retomada §7.6 (bot não reassume; "Passar pra IA" limpa silêncio) | parcial | gate de silêncio respeitado pelo worker `workers/ai-response-worker.ts:291`; endpoint `app/api/v1/conversations/[id]/reactivate-bot/route.ts:52` (UPDATE `bot_silenced_until: null`, guarda role≥agent `:37`); **sem UI** — nenhum botão chama o endpoint (`grep -r reactivate-bot hooks components app/app` → 0) | **G3-02** (handoff vira reassignment `assignee_kind`; a devolução p/ IA é o reassign inverso) |
| MCP `crm_request_human_handoff` (superfície p/ agentes externos) | implementado | `lib/mcp/tools/handoff.ts:17` (usa o orchestrator central), `:37,106` (atribuição round-robin best-effort por role mínimo) | upgrade (fila/horário/atendente-alvo) em **G6-01** |
| Baseline: `conversations.assigned_to_user_id` + `assigned_at` + FK + índices | implementado | `supabase/baseline.sql:1386-1387` (colunas), `:3005` (FK `auth.users` ON DELETE SET NULL), `:2376` (`idx_conversations_assigned`), `:2380` (`idx_conversations_open_unassigned`), `:1392` (`unread_count_for_assignee`) | — |
| Baseline: status `claimed`/`ai_handling` no CHECK de `conversations.status` | implementado | `supabase/baseline.sql:1407` (CHECK aceita `open/pending/resolved/claimed/ai_handling/closed/archived`); comentário sobre dualidade legado+EPIC-03 `:1414` | consolidação semântica em **G3-02** (`assignee_kind` desambigua `ai_handling`) |
| Baseline: colunas de handoff (`bot_silenced_until`, `last_handoff_at/reason`) + índice | implementado | `supabase/baseline.sql:1398-1400` (colunas), `:2292` (`conversations_bot_silenced_idx`) | — |
| Auditoria de mudança de dono (spec 13 §3 `conversation_assignment_events`) | ausente | tabela não existe (`grep -c conversation_assignment_events supabase/baseline.sql` → 0); hoje só `api_audit_log` actions `conversation.claimed`/`.released` (`app/api/v1/conversations/[id]/claim/route.ts:89-96`, `.../release/route.ts:67`) — sem from/to/reason estruturados | **G3-01** |
### B.3 — Contagem
implementado: **9** · parcial: **5** · ausente: **6** (dos quais 1 sem feature G* → INB-01 na inbox).
+7 -2
View File
@@ -100,8 +100,13 @@
"priority": 40,
"lane": "core",
"kind": "build",
"passes": false,
"verification": null
"passes": true,
"verification": {
"verdict": "PASS",
"by": "gov-verifier",
"at": "2026-07-16T20:12:00-0300",
"commit": "self"
}
},
{
"id": "G1-05",
+14
View File
@@ -72,3 +72,17 @@
por viewer. Catraca provada em probe (it.fails de assert válido → suíte RED).
- gov-verifier: PASS, hash-check OK. Apêndice A da spec 13 preenchido (20 linhas).
- Próxima sessão: G1-04 (auditoria de gap specs 04/05 vs código — sem deps).
## 2026-07-16 — sessão 5 do loop (core) — G1-04
- G1-04 (auditoria specs 04/05 vs código): Apêndice B da spec 13 preenchido —
20 itens (9 implementado / 5 parcial / 6 ausente), toda linha com arquivo:linha
conferida pelo verifier (20/20). Claim atômico e handoff §7.5 são reais;
ReassignDialog, AttendantStatusToggle e supervisor read-only ausentes.
- INB-01 aberto na inbox (proposal): supervisor read-only §10 sem feature G* e
conflitando com a matriz spec 13 §4 (manager org:write) — decisão do dono.
- Nota do verifier p/ sessão futura: linha "supervisor §10" cita
conversations/[id]/messages/route.ts:38 mas o POST real é
app/api/v1/messages/route.ts — ponteiro impreciso, conclusão correta.
- gov-verifier: PASS, hash-check OK (tree + inbox).
- Próxima sessão: G1-05 (modelo de dados alvo + matriz role×recurso, deps G1-04 ✓).