# Spec: Migrar Read Pipeline para FactQuery (Part 2/4)

> Source PRD: `.vibeflow/prds/fact-query-abstraction.md`
> Generated: 2026-04-09

## Objective

Migrar todos os 10 query sites em `src/arandu/read/*` e o pioneiro `client.py::get_all` para usar `FactQuery`, eliminando o não-determinismo reportado em produção e estabelecendo o invariante arquitetural de "única porta de entrada para queries de MemoryFact no read path".

## Context

Parte 1 construiu `FactQuery` sem usar em lugar nenhum. Parte 2 é onde o valor aparece: os 10 sites do read pipeline (retrieval, graph_retrieval, spreading_activation, query_expansion) são migrados, **corrigindo como efeito colateral o bug reportado pelo Personal Genius** (não-determinismo do retrieve com 5 runs idênticos retornando facts diferentes).

Também migra `client.py::get_all` — o único site que já tinha tiebreaker. Migra pelo padrão, não por correção, pra estabelecer que **todos** os reads de `MemoryFact` passam pelo builder.

Essa é a spec que ship v0.12.0 (minor bump) e fecha o achado 2c do Personal Genius. Os consumidores vão atualizar e ganhar determinismo sem mudar código deles — é bugfix transparente.

## Definition of Done

1. Os 10 query sites de `src/arandu/read/*` (listados no scope) foram migrados pra `FactQuery`. Nenhum deles contém mais `select(MemoryFact)` direto.
2. `client.py::get_all` também foi migrado pra `FactQuery` (pioneiro com tiebreaker — serve como exemplo de migração pra futuros sites e remove a última cópia solta do padrão `created_at DESC, id`).
3. Rodar `retrieve()` 10 vezes com query fixa contra memória fixa (sem writes entre runs) retorna `list[ScoredFact]` **idêntico** (mesma ordem, mesmos facts) em todas as 10 execuções. Esse teste vive em `tests/test_retrieve_determinism.py` como teste automatizado.
4. Todos os 443+ testes existentes do projeto continuam passando após a migração. Mocks que precisam atualizar (por causa da cadeia `session.execute(FactQuery.X().build())` em vez de `session.execute(select(MemoryFact)...)`) foram ajustados.
5. **Quality gate — teste de regressão arquitetural:** novo teste `tests/test_fact_query_invariant.py` que faz grep do código-fonte e falha se qualquer arquivo em `src/arandu/read/` ou `src/arandu/client.py` contém `select(MemoryFact)` fora do `_fact_query.py`. Whitelist explícita para point-lookups por ID que não precisam de ordering (os 4 helpers em `upsert.py` — ficam pra Parte 3).
6. `.vibeflow/decisions.md` atualizado com a entrada "FactQuery as single source of read queries for MemoryFact" (data 2026-04-09), explicando o invariante e o enforcement.
7. Bump de versão (`0.11.8` → `0.12.0`) — minor porque é bugfix relevante no read path, mesmo que não mude API pública.

## Scope

### Sites a migrar

#### `src/arandu/read/retrieval.py`

- **`semantic_search`** (linha atual ~63-130): substituir construção manual do `select(MemoryFact, similarity)` por:
  ```python
  query = (
      FactQuery.active(agent_id)
      .with_min_confidence(config.min_confidence)
      .with_embedding()
      .with_deferred_embedding()
      .order_by_similarity(query_embedding)
      .limit(pool_size)
  )
  if entity_keys is not None:
      query = query.for_entities(entity_keys)
  stmt = query.build()
  ```
  `similarity` vem no result row como label `"similarity"` — preservado pelo builder no caminho de `order_by_similarity`.

- **`keyword_search`** (linha atual ~149-205): substituir por:
  ```python
  query = (
      FactQuery.active(agent_id)
      .with_min_confidence(config.min_confidence)
      .matching_text(patterns)
      .with_deferred_embedding()
      .order_by_recency()  # agora com id como tiebreaker — FIX do bug reportado
      .limit(limit)
  )
  if entity_keys is not None:
      query = query.for_entities(entity_keys)
  stmt = query.build()
  ```

- **`_resolve_by_slug`** (linha atual ~399): esse é um `select(MemoryFact.entity_key).distinct()` — projeção pura, não é fetching de facts. **Mantém como está**. O builder é pra leitura de `MemoryFact` completo; projeções de columns distintos não caem no invariante. Adicionar ao whitelist do teste arquitetural.

#### `src/arandu/read/graph_retrieval.py`

- **`_build_edge_dicts`** (linha ~324): projeção `select(entity_key, entity_name).distinct(entity_key)` — mesma categoria do `_resolve_by_slug`. **Mantém + whitelist**.

- **`_fetch_facts_for_entities`** primary path (linha ~409): migrar pra:
  ```python
  query = (
      FactQuery.active(agent_id)
      .for_entities(entity_keys)
      .with_min_confidence(config.min_confidence)
      .order_by_recency()
      .limit(limit)
  )
  ```
  Temporal window condicional via `.within_temporal_window(start, end)`.

- **`_fetch_facts_for_entities`** fallback path (linha ~425): mesma migração, mas sem link table (o fallback era justamente pra quando link table não existia). **Decisão:** o fallback fica obsoleto — `FactQuery.for_entities` usa EXISTS subquery contra link table, e o link table sempre existe no schema atual. Remove o fallback inteiro.

#### `src/arandu/read/spreading_activation.py`

- **`_fetch_facts_by_ids`** (linha ~356): migrar pra `FactQuery.active(agent_id).by_ids(fact_ids).build()`. Sem ordering (recuperando por ID é point lookup, não ordering-sensitive).

- **`_fetch_neighbor_facts`** primary (linha ~377): migrar pra:
  ```python
  query = (
      FactQuery.active(agent_id)
      .for_entity(entity_key)
      .excluding_ids(exclude_ids)
      .order_by_recency()
      .limit(limit)
  )
  ```

- **`_fetch_neighbor_facts`** fallback (linha ~398): remover, mesmo motivo do `_fetch_facts_for_entities` fallback.

- **`_fetch_cluster_facts`** (linha ~425): migrar pra:
  ```python
  query = (
      FactQuery.active(agent_id)
      .for_cluster(cluster_id)
      .excluding_ids(exclude_ids)
      .order_by_recency()
      .limit(limit)
  )
  ```

#### `src/arandu/read/query_expansion.py`

- **`_fetch_entity_context`** (linha ~246): projeção `select(entity_key, value_text)` — outra projeção de colunas. **Mantém + whitelist** (projeção, não fetching).

#### `src/arandu/client.py`

- **`get_all`** (linha ~476): migrar para:
  ```python
  query = FactQuery.active(str(agent_id)).order_by_created().limit(limit).offset(offset)
  if entity_keys is not None:
      resolved, _invalid = await _resolve_entity_keys(session, str(agent_id), entity_keys)
      if resolved:
          query = query.for_entities(resolved)
      elif entity_keys:
          # todas inválidas → filtro vazio forçado (consistência com comportamento atual)
          query = query.for_entities([])
  stmt = query.build()
  ```
  Pioneiro com tiebreaker vira pioneiro do padrão.

### Testes novos

- **`tests/test_retrieve_determinism.py`** (novo): teste de regressão do bug reportado pelo Personal Genius. Mocka a session pra retornar facts com `valid_from` idêntico (simulando timestamps compartilhados de um mesmo write) e roda `retrieve_candidates` 10x, comparando listas de retorno. Deve ser idêntico em todas as execuções.

- **`tests/test_fact_query_invariant.py`** (novo): teste arquitetural. Lê `src/arandu/read/*.py` + `src/arandu/client.py` e procura por `select(MemoryFact)`. Falha se encontrar fora do whitelist:
  ```python
  WHITELIST = {
      "src/arandu/read/retrieval.py": ["_resolve_by_slug"],  # projection
      "src/arandu/read/graph_retrieval.py": ["_build_edge_dicts"],  # projection
      "src/arandu/read/query_expansion.py": ["_fetch_entity_context"],  # projection
  }
  ```

### Testes existentes a ajustar

Testes que mockam `session.execute` e verificam que um `select(MemoryFact)` específico foi chamado vão precisar ajustar pra esperar o `Select` que o `FactQuery` gera. Candidatos prováveis:

- `tests/test_read_pipeline.py`
- `tests/test_sdk_integration.py`
- `tests/test_retrieve_entity_filter.py`
- `tests/test_e2e.py`

O ajuste é: nos asserts que verificam o `Select` compilado, reconstruir o expected usando `FactQuery` da mesma forma que o código faz. Preserva 1:1 o comportamento testado.

### `.vibeflow/decisions.md`

Nova entrada no topo:

```markdown
## 2026-04-09 — FactQuery as single source of read queries for MemoryFact

**Context**: 26 query sites against MemoryFact had no shared abstraction,
25 of them lacked stable tiebreakers, and 14 duplicated the base filter
verbatim. Two production bugs (get_all pagination in March, retrieve
non-determinism in April) were manifestations of the same structural gap.

**Decision**: All read queries against `MemoryFact` in `src/arandu/read/`
and `src/arandu/client.py` MUST go through the `FactQuery` builder in
`src/arandu/_fact_query.py`. Point-lookups by ID and column projections
are exempt and whitelisted in the architectural regression test.

**Invariant**: `grep "select(MemoryFact)" src/arandu/read/ src/arandu/client.py`
returns only whitelisted call sites. Any new direct select is a
regression — the build fails via `tests/test_fact_query_invariant.py`.

**Trade-off**: One level of indirection between the caller and the raw
`Select`. Worth it — stable ordering is now impossible to forget, the
base filter lives in exactly one place, and future features (backups,
audit logs, analytics) can add new filters by extending the builder
instead of touching every call site.

**Related**: `.vibeflow/specs/fact-query-abstraction-part-1.md`,
`.vibeflow/specs/fact-query-abstraction-part-2.md`.
```

## Anti-scope

- **Migrar `src/arandu/write/*`** (reconcile, upsert, entity_resolution). Parte 3.
- **Migrar `src/arandu/background/*`** (consolidation, clustering, memify). Parte 4.
- **Migrar os 4 point-lookups por ID em `upsert.py`** (`_update_fact`, `_confirm_fact`, `_delete_fact` fetch the matched_fact_id). Esses são select-by-id determinísticos, entram no whitelist da Parte 3.
- **Mexer no reranker, `min_reranker_score`, ou cross-language.** Isso é a Spec 2 (investigation) separada, que agora fica destravada porque o baseline vai ser determinístico.
- **Mudar semântica dos filtros.** Se um site estava filtrando "diferente" do que deveria, preserva o comportamento atual. Correção de semântica é outro ticket.
- **Remover `FactQuery.for_entities` quando recebe lista vazia pra passar `True`.** Lista vazia no `EXISTS` retorna zero resultados — é o comportamento correto do "filtro ativo sem matches". Consistente com a spec `entity-keys-alias-resolution`.
- **Paralelismo novo** em `retrieve_candidates` (hoje é sequencial). Manter como está.
- **Teste de performance.** O `FactQuery` é só construção de `Select` — zero overhead de runtime. Sem necessidade de benchmark.

## Technical Decisions

### 1. Migrar `get_all` mesmo tendo tiebreaker já

**Decisão:** Migrar mesmo assim, pra estabelecer o padrão e eliminar o último site solto. Não é urgente como os outros, mas o valor é limpeza + consistência.

**Trade-off:** Risco mínimo (`get_all` tem testes) vs mensagem clara: "todos os reads passam pelo builder".

### 2. Remover fallback sem link table em `_fetch_facts_for_entities` e `_fetch_neighbor_facts`

**Decisão:** O fallback é código defensivo de antes do link table existir. O link table é criado em `init_db()` há meses, e todos os writes populam ele. O fallback nunca é exercitado na prática.

**Trade-off:** Remoção de código. Se algum usuário tiver um DB antigo sem link table, o SDK quebra — mas isso já quebraria em muitos outros lugares.

**Justificativa:** Morto. Confirmar via grep que `MemoryFactEntityLink` é referenciado no schema e migrations, e aí remover. Se tiver dúvida, adicionar migration check no startup.

### 3. Projeções (select de colunas) ficam whitelisted, não migradas

**Decisão:** `_resolve_by_slug`, `_build_edge_dicts`, `_fetch_entity_context` fazem `select(MemoryFact.column)` — não buscam o objeto inteiro. O builder é focado em `Select[MemoryFact]`. Forçar projeções a passarem pelo builder inflaria a API sem ganho.

**Trade-off:** O whitelist cresce. Mas as projeções são pequenas (<10 linhas cada), raramente mudam, e o risco de bug de ordering nelas é baixo (elas já não têm ordering crítico).

**Justificativa:** Pragmatismo. O invariante é "fetching de facts via builder", não "qualquer select que mencione MemoryFact".

### 4. Teste arquitetural faz grep de texto, não AST

**Decisão:** O teste usa `re.search` no conteúdo do arquivo procurando `r"select\(MemoryFact[^.]"` (negative lookahead pra evitar match em `select(MemoryFact.column)`).

**Trade-off:** Regex pode ter falso positivo/negativo. AST seria mais robusto mas complexo.

**Justificativa:** Simples. Se o regex virar fonte de falha, troca pra `libcst` depois. Hoje o regex resolve 99% dos casos.

### 5. Teste de determinismo mocka `session.execute` com lista fixa

**Decisão:** O teste não precisa rodar contra DB real. Mocka o session pra retornar uma lista fixa de facts com `valid_from` idêntico (simulando o cenário do bug). Depois compara o output de `retrieve_candidates` entre 10 runs.

**Trade-off:** Não cobre bug de ordering do PostgreSQL real (tipo heap order). Mas cobre o comportamento do builder + merge_and_rank, que é onde a estabilidade precisa estar garantida no código Python. Bug de PostgreSQL heap order é responsabilidade do tiebreaker no `ORDER BY` — que o builder já aplica por construção.

**Justificativa:** Testa o que o SDK controla. Se o tiebreaker tá aplicado (verificado em testes da Parte 1), o PostgreSQL vai respeitar.

### 6. Bump de versão minor (0.12.0), não patch

**Decisão:** Bump minor porque a mudança de comportamento (determinismo) é significativa do ponto de vista do consumidor, mesmo que API pública não mude.

**Trade-off:** Patch seria mais "correto" por SemVer (só bugfix interno). Mas consumidores que rodam testes de snapshot do retrieve vão ver diff — isso merece sinal.

**Justificativa:** Bump minor comunica "algo mudou no read path, verifique seus testes". É honesto.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): mantida. O builder retorna `Select[MemoryFact]` — o pattern de `await session.execute(stmt)` continua intacto.
- **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`): read pipeline continua linear. O `_resolve_entity_keys` upstream (da spec `entity-keys-alias-resolution`) continua vivendo no mesmo lugar e agora passa a lista resolvida pro `.for_entities()`.
- **Testing** (`.vibeflow/patterns/testing.md`): testes continuam mock-based. O teste de determinismo usa o mesmo padrão `FakeEmbeddingProvider` + session mockada.
- **Error Handling** (`.vibeflow/patterns/error-handling.md`): sem mudança. O builder não introduz novos caminhos de erro — erros de DB continuam propagando como antes.

## Risks

1. **Migração quebra mocks de testes existentes.** O maior risco da spec. Mitigação: migrar um arquivo por vez, rodar os testes afetados após cada migração, ajustar mocks incrementalmente. Cap de 2 tentativas de fix por teste quebrado.

2. **`order_by_similarity` retorna `Select[tuple[...]]` e mypy reclama no caller.** Mitigação: o caller (`semantic_search`) hoje já desempacota `(fact, similarity)` do result — o builder só preserva esse comportamento. Se mypy ficar confuso, usar `# type: ignore[...]` cirúrgico com código específico.

3. **Remoção do fallback sem link table quebra algum deploy antigo.** Mitigação baixa: o link table existe há meses, o schema é criado via `init_db()`. Se aparecer, o erro é imediato e claro.

4. **Teste arquitetural vira muita overhead de manutenção.** Mitigação: whitelist curta e documentada. Se crescer muito, refatorar. Hoje são ~3 exceções.

5. **Bump minor causa ruído pra consumidores que não esperam mudança no read.** Mitigação: release notes claras explicando que é bugfix de determinismo e nenhum retrabalho necessário.

6. **Algum site tem combinação de filtros que o builder não cobre.** Mitigação: o survey do PRD listou todos os filtros existentes. Se aparecer caso novo durante a migração, adicionar método ao builder antes de migrar aquele site (fazer isso cirúrgico na Parte 1 se descobrir durante a implementação).

## Dependencies

- `.vibeflow/specs/fact-query-abstraction-part-1.md` — precisa do `FactQuery` builder existindo com todos os métodos listados. Sem a Parte 1, esta spec não pode sequer começar.
