# Spec: Migrar Write Pipeline para FactQuery (Part 3/4)

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

## Objective

Migrar os 2 query sites de leitura em `src/arandu/write/reconcile.py` para `FactQuery`, preservando o invariante existente de "single entry point for MemoryFact creation" no write pipeline e expandindo o invariante de "single source of read queries" para cobrir também o write path.

## Context

Parte 2 migrou read + client. O invariante arquitetural cobre `src/arandu/read/*` e `client.py`. Mas `src/arandu/write/reconcile.py` também faz queries de leitura de `MemoryFact` — duas delas:

1. **`_fetch_entity_facts`** — busca facts ativos de uma entidade pra passar pro reconciler LLM.
2. **`_find_similar_facts`** — busca top-3 similares por distância vetorial via raw SQL (`text()`).

Os 4 point-lookups por ID em `upsert.py` (`_update_fact`, `_confirm_fact`, `_delete_fact`, fetch do matched_fact_id) **não** são migrados — são lookups de 1 linha por UUID, deterministicamente ordenados por construção (há no máximo 1 resultado). Entram no whitelist do teste arquitetural.

Esta spec também estende o teste arquitetural (criado na Parte 2) para cobrir `src/arandu/write/`. O invariante fica "reads de `MemoryFact` em `src/arandu/read/` + `src/arandu/client.py` + `src/arandu/write/reconcile.py` passam pelo builder".

## Definition of Done

1. `src/arandu/write/reconcile.py::_fetch_entity_facts` usa `FactQuery` e não contém mais `select(MemoryFact)` direto.
2. `src/arandu/write/reconcile.py::_find_similar_facts` ou (a) foi migrado para `FactQuery.order_by_similarity`, ou (b) está documentado como exceção no whitelist com justificativa técnica explícita (pgvector raw SQL não comporta no builder). Decisão tomada durante implementação — ambos caminhos aceitos.
3. Os 4 point-lookups por ID em `src/arandu/write/upsert.py` estão no whitelist do teste arquitetural com comentário explicando (são lookups por UUID, ordering trivial).
4. O teste `tests/test_fact_query_invariant.py` (criado na Parte 2) é atualizado para cobrir `src/arandu/write/`. Passa verde.
5. Invariante de fact **creation** preservado: `grep "MemoryFact(" src/arandu/write/` continua retornando no máximo 2 matches (`_add_fact` e `_update_fact`). Validado manualmente + via teste se já existir.
6. **Quality gate:** após a migração, `src/arandu/write/reconcile.py` não tem nenhuma duplicação do filtro `agent_id == X AND entity_key == Y AND valid_to IS NULL` — o filtro vive só dentro do `FactQuery`.
7. Todos os 443+ testes existentes continuam passando. Testes de `reconcile` que mockam a session vão precisar ajustar pra esperar o `Select` que vem do `FactQuery`.

## Scope

### Sites a migrar

#### `src/arandu/write/reconcile.py::_fetch_entity_facts`

Migração direta:

```python
# Antes
stmt = (
    select(MemoryFact)
    .where(
        MemoryFact.agent_id == agent_id,
        MemoryFact.entity_key == entity_key,
        MemoryFact.valid_to.is_(None),
    )
    .order_by(MemoryFact.valid_from.desc())
    .limit(50)
)

# Depois
stmt = (
    FactQuery.active(agent_id)
    .for_entity(entity_key)
    .order_by_recency()
    .limit(50)
    .build()
)
```

#### `src/arandu/write/reconcile.py::_find_similar_facts`

Este é o caso difícil. O código atual usa `text()` raw SQL com pgvector operators:

```python
query = text("""
    SELECT id,
           1 - (embedding_vec <=> CAST(:embedding AS vector)) as similarity
    FROM memory_facts
    WHERE user_id = :user_id AND entity_key = :entity_key
      AND valid_to IS NULL AND embedding_vec IS NOT NULL
    ORDER BY embedding_vec <=> CAST(:embedding AS vector)
    LIMIT 3
""")
```

**Caminho A — Migrar para `FactQuery.order_by_similarity`:**

```python
stmt = (
    FactQuery.active(agent_id)
    .for_entity(entity_key)
    .with_embedding()
    .order_by_similarity(embedding)
    .limit(3)
    .build()
)
# stmt retorna Select[tuple[MemoryFact, float]]
```

Isso funciona **se** o `FactQuery.order_by_similarity` da Parte 1 usar a mesma expressão SQLAlchemy (`MemoryFact.embedding_vec.cosine_distance(embedding)`) que o `semantic_search` já usa. O resultado semântico é equivalente ao raw SQL (o raw SQL existe porque na época não tinha suporte nativo via ORM — hoje tem).

**Caminho B — Manter raw SQL e adicionar ao whitelist:**

Se o caminho A tiver alguma incompatibilidade sutil (ex: queremos só o `id` + `similarity` sem carregar o `MemoryFact` inteiro por performance), documentar como exceção no whitelist do teste arquitetural:

```python
# src/arandu/write/reconcile.py::_find_similar_facts
# Exception: raw pgvector SQL returning only (id, similarity) tuples.
# Migration to FactQuery would force loading full MemoryFact rows
# which is ~10x the bytes for this hot-path reconciliation query.
```

**Decisão:** tentar A primeiro. Se funcionar identicamente (mesmos IDs retornados, mesma ordem, mesma performance), fica A. Se qualquer um desses três critérios falhar, cai pro B. A decisão final é tomada durante a implementação — não é algo que dê pra decidir sem testar.

#### Whitelist do teste arquitetural — adições

```python
# tests/test_fact_query_invariant.py — extensão do whitelist
WHITELIST = {
    # Já existente da Parte 2:
    "src/arandu/read/retrieval.py": ["_resolve_by_slug"],
    "src/arandu/read/graph_retrieval.py": ["_build_edge_dicts"],
    "src/arandu/read/query_expansion.py": ["_fetch_entity_context"],

    # Adicionado pela Parte 3:
    "src/arandu/write/upsert.py": [
        "_update_fact",  # point lookup by matched_fact_id UUID
        "_confirm_fact",  # point lookup by matched_fact_id UUID
        "_delete_fact",  # point lookup by matched_fact_id UUID
    ],
    # Adicionado se caminho B for escolhido em _find_similar_facts:
    # "src/arandu/write/reconcile.py": ["_find_similar_facts"],  # raw pgvector SQL
}
```

### Testes

- **`tests/test_reconcile.py`** (ajustar testes existentes): os testes que mockam `session.execute` pra simular fetch de entity facts vão precisar esperar o `Select` que vem do builder. Mudança de mock, não de asserção funcional.

- **Sem teste novo** específico desta spec. O teste arquitetural da Parte 2 é estendido, mas não é novo arquivo.

### Bump de versão

Patch (`0.12.0 → 0.12.1`). Mudança interna em write path, sem impacto user-visible. O invariante agora cobre um novo diretório, mas o consumidor não percebe.

## Anti-scope

- **`src/arandu/write/upsert.py` fact creation** (`_add_fact`, `_update_fact`). Esses instanciam `MemoryFact(...)` — estão cobertos pelo invariante existente de "single entry point for fact creation" (documentado em 2026-04-09). Não tocar aqui.
- **`src/arandu/write/entity_resolution.py`**. Não faz queries de `MemoryFact` — faz queries de `MemoryEntity` / `MemoryEntityAlias`. Fora do escopo do builder.
- **`src/arandu/write/informed_extract.py`**. Mesma coisa: queries de entities/aliases, não de facts.
- **Migrar `src/arandu/background/*`**. Parte 4.
- **Reescrever raw pgvector SQL como uma abstração geral.** Se cair no caminho B, documenta e para. Não vale introduzir um `FactQuery.raw_similarity()` method pra servir 1 caso.
- **Refatorar o reconciler LLM prompt ou a lógica de decisão ADD/UPDATE/NOOP/DELETE.** Só migra as queries. Comportamento 1:1.
- **Mexer em fact creation, entity resolution, ou qualquer outro stage do write pipeline.**

## Technical Decisions

### 1. Decisão caminho A vs B adiada para implementação

**Decisão:** O spec permite qualquer um dos dois caminhos, decidido na implementação quando der pra medir o impacto real.

**Trade-off:** Um spec mais "fechado" teria que escolher antes. Mas sem medir o comportamento do `FactQuery.order_by_similarity` contra o raw SQL em cenário real, a escolha é adivinhação.

**Justificativa:** O objetivo é o invariante cobrir write path. Seja via migração ou via whitelist explícita, o objetivo é atingido.

### 2. Point-lookups por ID em whitelist, não migrados

**Decisão:** `_update_fact`, `_confirm_fact`, `_delete_fact` fazem `select(MemoryFact).where(id == matched_fact_id)`. Isso é lookup por UUID primário — 1 resultado ou 0. Ordering trivial. Migrar para `FactQuery.active(agent_id).by_ids([matched_fact_id])` adicionaria ruído sem ganho.

**Trade-off:** Whitelist cresce com 3 exceções. Mas whitelist explícito com comentário é mais honesto do que criar abstração pra caso trivial.

**Justificativa:** Invariante é "fetching de múltiplos facts via builder". Lookups de 1 row por UUID primário não caem nessa categoria.

### 3. Não incluir `_fetch_entity_facts` no whitelist mesmo sendo "only one entity"

**Decisão:** `_fetch_entity_facts` é filtro por `entity_key` + ordering + limit — é o caso típico que o builder resolve. Migrar é obrigatório mesmo que retorne poucos registros.

**Justificativa:** Consistência e tiebreaker. A ordenação por `valid_from DESC` sem tiebreaker é exatamente o bug do Personal Genius, só que no write path. Preservar no formato atual é deixar a bomba-relógio.

### 4. Bump de versão patch

**Decisão:** `0.12.0 → 0.12.1`. Mudança interna sem impacto user-visible direto.

**Trade-off:** Poderia continuar em minor (0.13.0) pra sinalizar "arquitetura mudou mais". Mas pra consumidor, nada muda.

**Justificativa:** Patch é o certo. Se descobrirmos bug durante migração, patch facilita rollback.

### 5. Não atualizar `.vibeflow/decisions.md` (mantém entrada da Parte 2)

**Decisão:** A entrada criada na Parte 2 ("FactQuery as single source of read queries for MemoryFact") já menciona o invariante. Parte 3 só estende a cobertura.

**Trade-off:** Poderia adicionar "e no write/reconcile.py". Mas isso é detalhe de implementação, não decisão nova.

**Justificativa:** Uma entrada por decisão arquitetural. Partes subsequentes extendem sem entrada nova.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): mantida. O builder retorna `Select[MemoryFact]` compatível com o pattern atual.
- **Error Handling** (`.vibeflow/patterns/error-handling.md`): sem mudança. `reconcile_facts` continua fail-safe com default ADD — isso é comportamento de pipeline, não afetado pela migração de query.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based. Ajuste de mocks em `test_reconcile.py` sem novos testes.

## Risks

1. **Caminho A (migrar `_find_similar_facts`) tem diferença sutil de performance.** O raw SQL retorna só `(id, similarity)`. O builder retornaria o `MemoryFact` inteiro. Se isso for lento no hot path da reconciliação, o caminho B é obrigatório. Mitigação: medir durante a implementação. Se caminho A for >1.2x mais lento, cair pro B.

2. **Testes de `test_reconcile.py` quebram muitos mocks.** O reconciler tem cenários complexos (ADD, UPDATE, NOOP, DELETE), alguns com múltiplas chamadas à session. Mitigação: ajustar mocks incrementalmente, rodar cada classe de teste isoladamente.

3. **Point-lookups whitelisted viram ponto de divergência futura.** Alguém pode achar que porque `_update_fact` faz `select(MemoryFact).where(id == X)` pode então fazer `select(MemoryFact).where(...)` em outro lugar pensando "se eles fazem, eu posso". Mitigação: comentário explícito no whitelist explicando por que (ordering trivial) e aviso no `.vibeflow/decisions.md` (se relevante).

4. **Caminho A introduz mudança de tipo de retorno em `_find_similar_facts`.** Hoje retorna lista de tuplas `(id, similarity)`. Com `FactQuery.order_by_similarity`, retorna `Select[tuple[MemoryFact, float]]`. O caller precisa ajustar pra desempacotar `(fact, similarity)` e usar `fact.id` em vez de `id` direto. Mitigação: ajustar o caller junto na mesma spec. Teste unitário cobre.

## Dependencies

- `.vibeflow/specs/fact-query-abstraction-part-1.md` — builder precisa existir.
- `.vibeflow/specs/fact-query-abstraction-part-2.md` — teste arquitetural precisa existir (Parte 3 o estende, não cria).
