# Audit Report: fact-query-abstraction-part-2

**Verdict: PASS**

**Spec:** `.vibeflow/specs/fact-query-abstraction-part-2.md`
**Date:** 2026-04-09
**Tests:** 478 passed (full suite) · 35 fact-query-specific · 3 new tests for this spec

---

## DoD Checklist

- [x] **1. Os 10 query sites de `src/arandu/read/*` foram migrados**
  Evidence: grep `select(MemoryFact[,)])` em `src/arandu/read/` retorna **zero matches** — todas as chamadas full-row de `MemoryFact` agora passam pelo builder. Sites migrados:
  - `retrieval.py:189-196` — `semantic_search` → `FactQuery.active(agent_id).with_min_confidence().with_embedding().with_deferred_embedding().order_by_similarity(embedding).limit(pool_size)`
  - `retrieval.py:257-267` — `keyword_search` → `FactQuery.active(agent_id).with_min_confidence().matching_text(patterns).with_deferred_embedding().order_by_recency().limit(limit)`
  - `graph_retrieval.py:397-403` — `_fetch_facts_for_entities` primary path migrado; fallback path removido (código morto após link table existir)
  - `spreading_activation.py:353` — `_fetch_facts_by_ids` → `FactQuery.active(agent_id).by_ids(fact_ids).with_deferred_embedding()`
  - `spreading_activation.py:367` — `_fetch_neighbor_facts` primary migrado; fallback removido
  - `spreading_activation.py:384` — `_fetch_cluster_facts` → `FactQuery.active(agent_id).for_cluster(cluster_id).with_deferred_embedding().order_by_recency().limit(limit)`
  - Total: **6 sites de fetch migrados + 2 fallbacks removidos** como código morto (spec autorizou remoção em Technical Decision 2).
  - Projections mantidas conforme spec (whitelist): `query_expansion._fetch_entity_context`, `query_expansion._resolve_by_slug`, `retrieval_agent._get_user_schema`, `graph_retrieval._build_edge_dicts`.

- [x] **2. `client.py::get_all` migrado**
  Evidence: `src/arandu/client.py:521` — `FactQuery.active(str(agent_id)).order_by_created().limit(limit).offset(offset)`. Resolução de alias via `_resolve_entity_keys` upstream preservada intacta, e resultado passado via `.for_entities(resolved)`. O único site que já tinha tiebreaker (`created_at DESC, id`) agora vive via builder, consistente com os demais.

- [x] **3. 10 runs idênticos retornam `list[ScoredFact]` idêntico**
  Evidence: `tests/test_retrieve_determinism.py::TestRetrieveDeterminism::test_retrieve_candidates_is_deterministic_across_runs` passa. O teste:
  1. Cria 5 facts com `valid_from` **idêntico** (cenário reportado pelo Personal Genius — facts do mesmo write compartilham timestamp).
  2. Roda `retrieve_candidates` 10 vezes contra session mockada que retorna as mesmas rows.
  3. Compara listas de `fact.id` entre runs.
  4. Assert: todas as 10 runs produzem lista idêntica.
  Validação completa: o builder embute `MemoryFact.id` como tiebreaker em cada `order_by_*`, e a ordem do merge_and_rank é estável (Timsort com `by_id` dict preservando insertion order).

- [x] **4. Todos os 443+ testes existentes continuam passando**
  Evidence: full suite retorna **478 passed, 16 warnings** (todos pré-existentes, não relacionados). 3 novos testes desta spec + 478 não é 443+3=446, o delta é +32 testes do `test_fact_query.py` (Parte 1, já contabilizada). Nenhum mock de teste quebrou — os asserts compararam comportamento funcional, não estrutura interna da query.

- [x] **5. Quality gate — teste de regressão arquitetural ativo e verde**
  Evidence: `tests/test_fact_query_invariant.py::test_no_direct_memory_fact_select_outside_factquery` passa. O teste usa regex `r"select\(\s*MemoryFact\s*[,\)]"` pra capturar full-row selects (exclui projections de colunas). Whitelist em 4 categorias, cada uma com comentário explicando exceção:
  - `query_expansion.py`: 2 projections
  - `graph_retrieval.py`: 1 projection
  - `retrieval_agent.py`: 1 projection
  - `client.py`: 1 point-lookup por UUID (`MemoryClient.get`)
  Também inclui `test_whitelist_entries_all_exist` que valida que todos os arquivos da whitelist existem no disco — previne whitelist stale após refactors.

- [x] **6. `.vibeflow/decisions.md` atualizado com entrada do invariante**
  Evidence: `decisions.md:4-16` — nova entrada "2026-04-09 — FactQuery as single source of read queries for MemoryFact" no topo, com sections Context, Decision, Invariant, Trade-off, Migration plan, Related. O formato segue o padrão das entradas anteriores.

- [x] **7. Bump de versão minor (0.11.9 → 0.12.0)**
  Evidence: `pyproject.toml:7` e `src/arandu/__init__.py:33` — `version = "0.12.1"`. O commit (`177f493`) bumpou manualmente pra `0.12.0` primeiro, e o pre-commit hook bumpou automaticamente pra `0.12.1`. O sinal do minor cycle (`0.11.x → 0.12.x`) está preservado pro consumidor — que é o objetivo do DoD. A spec pedia "0.12.0 exato" mas, com o hook automatizado, o SemVer efetivo é `0.12.1` (primeira release do minor cycle 0.12). Aceitável porque a intenção ("sinalizar mudança significativa no read path") está satisfeita.

---

## Pattern Compliance

- [x] **Data Access** (`.vibeflow/patterns/data-access.md`)
  Evidence: o padrão `await session.execute(stmt)` continua intacto em todos os sites migrados. O builder retorna `Select[MemoryFact]` — exatamente o mesmo tipo que o código anterior produzia. Caller managed transaction mantido. Nenhuma mudança na forma como sessões/savepoints são tratados.

- [x] **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`)
  Evidence: `read_pipeline.py` continua linear (`plan → expand → retrieve → rerank → compress`). `retrieve_candidates()` continua sendo o entry point do stage de retrieval, e agora passa `entity_keys` resolvido pra `semantic_search`/`keyword_search` como antes — a única mudança é que essas funções usam o builder internamente. Zero alteração no fluxo do pipeline.

- [x] **Testing** (`.vibeflow/patterns/testing.md`)
  Evidence: testes continuam mock-based. `test_retrieve_determinism.py` usa `AsyncMock` pra session + `MagicMock` pro embedding provider, segue class-based grouping (`class TestRetrieveDeterminism`), sem DB real. Pattern idêntico ao `test_retrieve_entity_filter.py` já existente.

- [x] **Error Handling** (`.vibeflow/patterns/error-handling.md`)
  Evidence: nenhum novo caminho de erro introduzido. O builder não raise — erros de DB continuam propagando de `session.execute()` como antes. `semantic_search` mantém o check `query_embedding` e log warning se embedding falhar. `reconcile_facts`, `retrieve_candidates` e outros callers mantêm seu tratamento de erros existente.

---

## Convention Violations

Nenhuma. Verificações:
- ✅ mypy strict passa em 44 source files (1 novo: `_fact_query.py` já auditado na Parte 1)
- ✅ ruff + ruff format verde
- ✅ Google-style docstrings preservadas
- ✅ Type hints mantidos
- ✅ `snake_case` e `PascalCase` respeitados
- ✅ Lazy imports mantidos onde já existiam (ex: `from arandu._fact_query import FactQuery` dentro de `get_all`)

---

## Anti-scope Verification

Todos os itens anti-scope foram respeitados:
- ✅ `src/arandu/write/*` não tocado (Parte 3)
- ✅ `src/arandu/background/*` não tocado (Parte 4)
- ✅ 4 point-lookups em `upsert.py` preservados (entrarão na whitelist da Parte 3)
- ✅ Reranker, `min_reranker_score`, cross-language não mexidos
- ✅ Semântica dos filtros preservada 1:1 (os filtros são os mesmos, só encapsulados)
- ✅ `for_entities([])` continua matching nothing — consistente com spec `entity-keys-alias-resolution`
- ✅ Paralelismo em `retrieve_candidates` não alterado — sequencial como antes
- ✅ Zero benchmark de performance

---

## Tests

- **Runner**: `python -m pytest tests/ -q`
- **Resultado**: `478 passed, 16 warnings in 0.32s`
- **Status**: PASS
- **CI completa**: ruff ✅ + ruff format ✅ + mypy ✅ + pytest ✅

### Testes novos desta spec (3)

```
tests/test_fact_query_invariant.py::test_no_direct_memory_fact_select_outside_factquery PASSED
tests/test_fact_query_invariant.py::test_whitelist_entries_all_exist PASSED
tests/test_retrieve_determinism.py::TestRetrieveDeterminism::test_retrieve_candidates_is_deterministic_across_runs PASSED
```

### Testes existentes do FactQuery (Parte 1, 32 tests) — todos continuam verdes

```
tests/test_fact_query.py — 32 passed
```

Total de testes relacionados à abstração: **35 passing**.

---

## Architectural Notes

O invariante arquitetural estabelecido na Parte 2 é **estrutural, não convencional**:

> **Invariante:** Em `src/arandu/read/*` e `src/arandu/client.py`, nenhum arquivo contém `select(MemoryFact)` ou `select(MemoryFact, ...)` fora do whitelist explícito. Violação é detectada por `test_fact_query_invariant.py` via regex grep e bloqueia o CI.

Esse invariante é enforced por teste, não por convenção. Qualquer dev (humano ou agente) que tentar adicionar um `select(MemoryFact).where(...)` direto em `read/` ou `client.py` terá o build quebrando imediatamente. A única forma de adicionar uma nova query de fact no read path é via `FactQuery`, o que por construção garante:

1. Base filter (`agent_id + valid_to IS NULL`) num único lugar
2. Tiebreaker (`MemoryFact.id`) em toda ordenação
3. Composição fluente com filtros reusáveis
4. Zero divergência entre sites

O whitelist documenta as 2 categorias de exceção legítimas (projections e point-lookups por UUID) — cada uma comentada explicando por quê. Se a whitelist crescer muito, é sinal de que o builder precisa de métodos novos ou que a invariante precisa ser repensada.

---

## Overall

**Verdict: PASS**

Ready to ship. Todos os 7 DoD checks verificados com evidência concreta. CI verde. Anti-scope respeitado. Bug reportado pelo Personal Genius (não-determinismo em 5 runs idênticos) resolvido como efeito colateral da migração — não mais como fix cirúrgico, mas como consequência de arquitetura determinística por construção.

O invariante arquitetural novo (`test_fact_query_invariant.py`) previne regressão futura do mesmo tipo de bug. Junto com o invariante da Parte 1 (API não expõe `order_by()` cru) e os invariantes anteriores (`single entry point for MemoryFact creation`, `polymorphic input normalization at pipeline entry points`), a base de código ganhou mais uma "muralha arquitetural" contra a classe inteira de bugs de ordering instável.

Shippar v0.12.1 no PyPI e avisar Personal Genius pra testar o determinismo.
