# Spec: Migrar Background Jobs para FactQuery (Part 4/4)

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

## Objective

Migrar os 7 query sites em `src/arandu/background/*` para `FactQuery`, fechando o ciclo do invariante "todas as queries de leitura de `MemoryFact` passam pelo builder" no SDK inteiro.

## Context

Partes 1-3 construíram o builder e migraram read + client + write/reconcile. Parte 4 cobre os background jobs: consolidation, clustering, memify. Esses jobs fazem full scans de facts (sem limit) ou scans por entity/cluster — todos sem tiebreaker hoje.

Apesar de não serem hot paths user-facing, esses jobs afetam a qualidade da memória de longo prazo:

- **Clustering** agrupa facts por similaridade — se a ordem de input muda entre runs, os clusters resultantes podem variar, gerando drift silencioso.
- **Consolidation** gera meta-observations baseadas nos facts recuperados — mesma história.
- **Memify** computa vitality e detecta merges — ordenação instável aqui afeta quais facts são considerados "stale" primeiro.

Tudo isso é argumento pra estabilidade determinística também em background. Mesmo que o usuário não perceba na hora, o agregado de runs ao longo de semanas/meses compõe drift que é quase impossível de debugar depois.

Depois da Parte 4, o teste arquitetural cobre **todo** `src/arandu/` — qualquer novo `select(MemoryFact)` fora do whitelist vira erro de build. O invariante fica completo.

## Definition of Done

1. Os 7 query sites de `src/arandu/background/*` (listados no scope) foram migrados para `FactQuery`. Nenhum deles contém mais `select(MemoryFact)` direto.
2. `tests/test_fact_query_invariant.py` (criado na Parte 2, estendido na Parte 3) agora cobre também `src/arandu/background/*`. Passa verde.
3. Whitelist final do teste arquitetural é mínima e cada exceção tem comentário justificando (projection de colunas, point-lookup por ID, ou raw pgvector).
4. Testes existentes de background (`test_clustering.py`, `test_consolidation.py`, `test_memify.py`, se existirem) continuam passando. Mocks ajustados onde necessário.
5. Rodar `cluster_user_facts` duas vezes contra a mesma memória fixa retorna clusters idênticos (teste de determinismo backgroudn em `tests/test_background_determinism.py`).
6. **Quality gate — invariante final:** após a Parte 4, `grep "select(MemoryFact[^.])" src/arandu/ | grep -v _fact_query.py | grep -v tests/` retorna **somente** os sites whitelisted. Qualquer resultado extra é regressão.
7. `.vibeflow/decisions.md` ganha atualização curta ("invariante expandido para background jobs — ciclo completo"). Uma seção nova opcional ou adendo na entrada da Parte 2.
8. `.vibeflow/patterns/data-access.md` ganha a seção "Querying MemoryFact" explicando o invariante e exemplificando uso do `FactQuery`. Isso é o pattern doc que guia features futuras.

## Scope

### Sites a migrar

#### `src/arandu/background/consolidation.py`

- **`run_consolidation`** (linha ~323): full scan de facts ativos. Migrar para:
  ```python
  stmt = (
      FactQuery.active(agent_id)
      .order_by_recency()
      .build()
  )
  facts = list((await session.execute(stmt)).scalars().all())
  ```
  Full scan sem limit — o `FactQuery` não força limit, só obriga ordering estável.

- **`run_profile_consolidation`** (linha ~464): facts por entidade. Migrar para:
  ```python
  stmt = (
      FactQuery.active(agent_id)
      .for_entity(canonical_key)
      .order_by_recency()
      .limit(20)
      .build()
  )
  ```

#### `src/arandu/background/clustering.py`

- **`cluster_user_facts`** scan (linha ~163): full scan com filtro temporal. Migrar para:
  ```python
  stmt = (
      FactQuery.active(agent_id)
      .within_temporal_window(cutoff, None)
      .order_by_recency()
      .build()
  )
  ```

- **`cluster_user_facts`** repr lookup (linha ~190): `DISTINCT ON` projection — **projeção de colunas, não fetching de facts**. Mantém + whitelist.

- **`rebuild_clusters_for_corrected_facts`** (linha ~319): `select(MemoryFact.cluster_id).distinct()` — **projeção**. Mantém + whitelist.

- **`rebuild_clusters_for_corrected_facts`** per-cluster (linha ~342): full scan de um cluster. Migrar para:
  ```python
  stmt = (
      FactQuery.active(agent_id)  # agent_id inferido do cluster
      .for_cluster(cluster_id)
      .order_by_recency()
      .build()
  )
  ```

#### `src/arandu/background/memify.py`

- **`run_memify`** step 1 (linha ~207): full scan. Migrar para:
  ```python
  stmt = FactQuery.active(agent_id).order_by_recency().build()
  ```

- **`run_memify`** step 3 (linha ~243): full scan com embeddings. Migrar para:
  ```python
  stmt = (
      FactQuery.active(agent_id)
      .with_embedding()
      .order_by_recency()
      .build()
  )
  ```

### Whitelist final

```python
# tests/test_fact_query_invariant.py — versão final
WHITELIST = {
    # Read path projections
    "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"],

    # Write path point-lookups by UUID
    "src/arandu/write/upsert.py": [
        "_update_fact",
        "_confirm_fact",
        "_delete_fact",
    ],

    # Client point-lookup by UUID
    "src/arandu/client.py": ["get"],  # MemoryClient.get(fact_id)

    # Background projections (if any kept)
    "src/arandu/background/clustering.py": [
        "cluster_user_facts_repr",  # DISTINCT ON projection
        "rebuild_clusters_corrected_scan",  # distinct cluster_id projection
    ],

    # Optional: if Part 3 path B was chosen
    # "src/arandu/write/reconcile.py": ["_find_similar_facts"],
}
```

### Testes

- **`tests/test_background_determinism.py`** (novo arquivo): teste que roda `cluster_user_facts` 2x contra memória fixa (mockada) e compara os clusters resultantes. Deve ser idêntico. Mesmo padrão do `test_retrieve_determinism` da Parte 2, mas pro background.

- **Ajuste de mocks** em `test_clustering.py`, `test_consolidation.py`, `test_memify.py` conforme necessário.

- **`tests/test_fact_query_invariant.py`** estendido para cobrir `src/arandu/background/`.

### Docs internas

- **`.vibeflow/patterns/data-access.md`**: nova seção "Querying MemoryFact":

```markdown
## Querying MemoryFact

All read queries against `MemoryFact` MUST go through `FactQuery` in
`src/arandu/_fact_query.py`. This is an architectural invariant enforced
by `tests/test_fact_query_invariant.py`.

### Why

Between March and April 2026, two production bugs arose from the same
structural gap: query sites constructing `select(MemoryFact).order_by(...)`
manually without stable tiebreakers. First in `get_all` (pagination
instability), then in `semantic_search` + `keyword_search` (retrieve
non-determinism). The root cause was not missing tiebreakers per se — it
was the absence of any abstraction that forced tiebreakers to exist.

### The builder

Use `FactQuery.active(agent_id)` or `FactQuery.all_versions(agent_id)`
as entry point. Chain filters, then terminate with an `order_by_*` method
(all include `MemoryFact.id` as tiebreaker) and `.build()`:

[code example]

### Exceptions

Two classes of queries are allowed to bypass the builder, listed in
the test's whitelist:

1. **Column projections** (`select(MemoryFact.entity_key).distinct()`):
   the builder is focused on fetching full fact rows. Projections that
   return only specific columns are rare, small, and not ordering-sensitive.

2. **Point-lookups by UUID** (`select(MemoryFact).where(id == uuid)`):
   these always return 0 or 1 row. Ordering is trivial, tiebreaker is
   meaningless.

Any new query outside these two categories must use the builder.
```

- **`.vibeflow/decisions.md`**: pequeno adendo na entrada "FactQuery as single source of read queries for MemoryFact":

```markdown
**2026-04-09 update (Part 4 complete)**: invariant now covers the full
`src/arandu/` tree including background jobs. Enforcement test
`test_fact_query_invariant.py` runs against all submodules. The 4-part
migration (builder → read → write → background) is complete.
```

### Bump de versão

Patch (`0.12.1 → 0.12.2`). Mudança interna em background jobs. Consumidor não percebe.

## Anti-scope

- **Refatorar lógica de clustering, consolidation ou memify.** Só migra queries, preserva comportamento.
- **Adicionar novos tipos de ordenação ao `FactQuery`** além do que já existe. Se algum background precisar de ordering diferente, adiciona na Parte 1 (revisitar) ou vive com o que tem.
- **Mexer em jobs de sleep-time compute** (`src/arandu/background/sleep_time.py`). Esses jobs não fazem queries de `MemoryFact` — fazem queries de `MemoryEntity`. Fora do escopo.
- **Atualizar docs user-facing** (`docs/en/`, `docs/pt-BR/`). A mudança é 100% interna. Se quiser documentar pra consumidores, vira spec separada.
- **Benchmark de performance antes/depois.** O `FactQuery` é constructor puro de `Select` — overhead zero de runtime. Não vale medir.

## Technical Decisions

### 1. Full scans sem limit continuam sem limit

**Decisão:** `run_consolidation`, `cluster_user_facts`, `run_memify` fazem full scans da memória do agent. O `FactQuery` não força limit — é opcional. Full scan permanece full scan.

**Trade-off:** Poderia forçar limit por "segurança" (ex: limit=10000). Mas isso muda comportamento e arrisca bug silencioso (background job processando só 10k facts de uma memória grande).

**Justificativa:** Preservar comportamento 1:1. Se full scan virar dor, vira spec separada de "paginated background jobs", não casa nesta.

### 2. Determinismo em background importa mesmo sem user-facing

**Decisão:** Forçar `order_by_recency()` em todos os full scans, mesmo que o código atual não tenha ordering explícito em alguns lugares.

**Trade-off:** Adiciona uma linha de sort no DB que não existia antes. Pequeno custo de performance.

**Justificativa:** Clustering e consolidation são sensíveis à ordem de input. Sem ordering determinístico, os outputs variam entre runs — drift silencioso. O custo é pequeno (query plan do Postgres reutiliza index `valid_from` + `id`), mas elimina fonte de flutuação.

### 3. Whitelist cresce mas fica claramente classificada

**Decisão:** A whitelist final tem 3 categorias documentadas: projections, point-lookups, pgvector raw SQL (se caminho B da Parte 3). Cada exceção tem comentário explicando em qual categoria cai.

**Trade-off:** Whitelist longa pode dar falsa sensação de "o invariante tem muitas exceções". Na real, são categorias bem definidas que não são "facts fetching".

**Justificativa:** Transparência é preferível a regras invisíveis.

### 4. `MemoryClient.get(fact_id)` entra no whitelist

**Decisão:** O `get()` do CRUD faz `select(MemoryFact).where(id == parsed_id)` — point lookup. Não migra, entra no whitelist.

**Trade-off:** `get` usa `all_versions` conceitualmente (não filtra `valid_to`). Poderia migrar pra `FactQuery.all_versions(agent_id).by_ids([parsed_id]).build()`. Mas esse `get` não precisa de `agent_id` no lookup — o ID é único globalmente.

**Justificativa:** Point lookup por UUID primário. Mesma categoria dos point lookups em `upsert.py`.

### 5. Sem entrada nova em `.vibeflow/decisions.md`, só adendo

**Decisão:** A decisão arquitetural foi feita na Parte 2 e registrada lá. Parte 4 só estende a cobertura.

**Trade-off:** Histórico de decisões fica mais "fragmentado". Mas criar entrada nova pra "invariante expandido" seria poluição.

**Justificativa:** Uma entrada por decisão. Extensões viram adendos datados.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): atualizado com a seção "Querying MemoryFact" (escopo desta spec).
- **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`): background jobs mantêm estrutura atual, só a camada de query muda.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based, class grouping. Teste de determinismo novo segue o padrão do `test_retrieve_determinism` da Parte 2.

## Risks

1. **Background jobs têm cenários de teste complexos.** Clustering especialmente tem lógica de seleção de representative fact, grouping por embedding, etc. Mocks podem ser difíceis de ajustar. Mitigação: migrar um job por vez (consolidation → clustering → memify), rodar testes após cada, debugar isolado.

2. **Full scan com ordering pode ser lento em memórias muito grandes.** Adicionar `ORDER BY valid_from DESC, id` em um scan de 100k+ facts tem custo. Mitigação: index composto `(agent_id, valid_from, id)` pode ser necessário. Se for, adicionar como migration separada (`alembic`).

3. **Whitelist final fica difícil de auditar.** Com 7-8 exceções, o desenvolvedor que abrir o whitelist pode não entender todas. Mitigação: comentários explícitos por exceção classificando em categoria (projeção, point-lookup, pgvector).

4. **Alguém adiciona nova query de fact num background job futuro sem usar o builder.** Mitigação: é exatamente isso que o teste arquitetural impede. Novo `select(MemoryFact)` fora do whitelist → CI quebra.

5. **`cluster_user_facts` tinha comportamento não-determinístico implícito que era aceitável.** Se clusters mudarem entre runs por conta do fix, pode expor tests que dependiam do comportamento antigo. Mitigação: rodar suite completa, ajustar testes que falharem com comportamento agora correto.

## Dependencies

- `.vibeflow/specs/fact-query-abstraction-part-1.md` — builder precisa existir.
- `.vibeflow/specs/fact-query-abstraction-part-2.md` — teste arquitetural precisa existir.
- `.vibeflow/specs/fact-query-abstraction-part-3.md` — write/reconcile já migrado.
