# Spec: Write Pipeline DX Fixes

> Generated from `.vibeflow/prds/write-pipeline-dx-fixes.md` on 2026-03-19
> Budget: ≤ 6 files

## Objective

Garantir que toda entity resolvida tenha um row em `memory_entities` e tornar `facts_deleted`/`facts_unchanged` consistentes com os outros campos do `WriteResult` (retornar `list` com detalhes, não `int`).

## Context

A equipe de integração reportou dois gaps:

1. **`memory_entities` esparsa**: O entity resolution insere na tabela apenas quando cria entities novas (`_create_entity()`). Entities resolvidas via exact/fuzzy/LLM match não geram upsert. Facts e relations referenciam entity keys que não existem na tabela. Background jobs (importance scoring, summary refresh, spreading activation) leem de `memory_entities` e ficam cegos — não operam sobre entities sem row.

2. **Inconsistência de tipos no `WriteResult`**: `facts_added`, `facts_updated` e `entities_resolved` retornam `list[dict]`, mas `facts_deleted` e `facts_unchanged` retornam `int`. Quem consome o SDK não sabe quais facts ficaram iguais ou foram deletados.

## Definition of Done

1. **Entity upsert pós-resolve**: Após o entity resolution retornar o `entity_map`, o pipeline faz upsert via `create_or_update_entity()` para toda entity resolvida (não apenas novas). Validado por teste que verifica chamada a `create_or_update_entity` para entities com `is_new=False`.

2. **`facts_unchanged` é `list`**: `WritePipelineResult.facts_unchanged` e `WriteResult.facts_unchanged` são `list` (não `int`). Cada item é um dict com `fact_text`, `subject`, `matched_fact_id`. Validado por teste.

3. **`facts_deleted` é `list`**: `WritePipelineResult.facts_deleted` e `WriteResult.facts_deleted` são `list` (não `int`). Cada item é um dict com `fact_text`, `subject`, `matched_fact_id`. Validado por teste.

4. **`UpsertResult` atualizado**: `UpsertResult.confirmed` e `UpsertResult.deleted` mudam de `int` para `list[dict]`. O pipeline consome as listas corretamente.

5. **Testes existentes ajustados**: Testes que comparavam `facts_unchanged` ou `facts_deleted` com `int` (ex: `assert result.facts_unchanged == 0`) são atualizados para comparar com `len()` da lista ou verificar conteúdo.

## Scope

### 1. `src/arandu/write/pipeline.py` — Entity upsert + result lists

- Após `resolve_entities()` retornar `entity_map`, iterar sobre entities e chamar `create_or_update_entity()` para cada uma (dentro de savepoint, com try/except fail-safe)
- Construir `facts_unchanged` e `facts_deleted` como listas de dicts a partir de `decisions` (mesmo padrão de `facts_added`/`facts_updated`)
- Atualizar `WritePipelineResult`: mudar `facts_unchanged: int = 0` → `facts_unchanged: list = field(default_factory=list)`, idem `facts_deleted`

### 2. `src/arandu/client.py` — WriteResult type change

- Mudar `WriteResult.facts_unchanged: int = 0` → `facts_unchanged: list = field(default_factory=list)`
- Mudar `WriteResult.facts_deleted: int = 0` → `facts_deleted: list = field(default_factory=list)`

### 3. `src/arandu/write/upsert.py` — UpsertResult type change

- Mudar `UpsertResult.confirmed: int = 0` → `confirmed: list = field(default_factory=list)`
- Mudar `UpsertResult.deleted: int = 0` → `deleted: list = field(default_factory=list)`
- Em `execute_upsert()`: popular as listas com dicts `{"fact_text": ..., "subject": ..., "matched_fact_id": ...}` ao invés de incrementar contadores
- Ajustar log de `result.confirmed` e `result.deleted` para usar `len()`

### 4. `tests/test_write_pipeline.py` — Ajustar assertions

- Testes que comparam `facts_unchanged == 0` ou `facts_deleted == 0` devem comparar com `len(...) == 0`
- Adicionar teste para entity upsert pós-resolve (mock de `create_or_update_entity` chamado para entities `is_new=False`)
- Adicionar teste que verifica que `facts_unchanged` retorna lista com detalhes

### 5. `docs/en/concepts/write-pipeline.md` + `docs/pt-BR/concepts/write-pipeline.md` — Atualizar WriteResult

- Atualizar seção WriteResult: `facts_unchanged` e `facts_deleted` agora retornam listas, não inteiros

## Anti-scope

- **Migração retroativa** de entities já no banco que estão faltando em `memory_entities`
- **Mudança em background jobs** — eles já leem de `memory_entities`, não precisam mudar
- **Mudança no read pipeline** — não afetado
- **FK constraint** entre `MemoryFact.entity_key` e `MemoryEntity.canonical_key`
- **Incremento de `fact_count`** na `MemoryEntity` durante upsert de facts — `create_or_update_entity()` já faz `fact_count + 1` no ON CONFLICT, o que é suficiente como aproximação
- **Mudança na assinatura de `execute_upsert()`** — a interface não muda, só os tipos internos do result

## Technical Decisions

### 1. Entity upsert no pipeline vs no entity resolution
**Decisão**: No pipeline (`pipeline.py`), após `resolve_entities()` retornar.
**Justificativa**: O entity resolution é responsável por resolver nomes → keys. O upsert de entities na tabela `memory_entities` é uma preocupação de persistência que pertence ao pipeline. Manter separação de responsabilidades. `create_or_update_entity()` é idempotente (ON CONFLICT DO UPDATE) — safe pra chamar sempre.

### 2. Formato dos dicts em `facts_unchanged` e `facts_deleted`
**Decisão**: `{"fact_text": str, "subject": str, "matched_fact_id": str | None}`.
**Justificativa**: Mesmo padrão de `facts_added`/`facts_updated` (dict com fact_text e subject). O `matched_fact_id` é relevante porque NOOP e DELETE operam sobre facts existentes — o consumidor precisa saber qual fact foi confirmado ou deletado.

### 3. Fail-safe no entity upsert
**Decisão**: Cada entity upsert em try/except com `logger.warning()`. Falha em uma entity não aborta as outras.
**Justificativa**: Segue o pattern existente de fail-safe do pipeline. Se o banco rejeitar um upsert (constraint violation, connection issue), o pipeline continua. A entity já foi resolvida — o upsert na tabela é best-effort pra popular background jobs.

### 4. `UpsertResult.confirmed`/`deleted` como `list` vs manter `int` e construir lista no pipeline
**Decisão**: Mudar pra `list` no `UpsertResult`.
**Justificativa**: O `execute_upsert()` é quem tem acesso aos detalhes de cada decision (fact_text, subject, matched_fact_id). Construir a lista lá e propagar é mais limpo do que reconstruir no pipeline a partir de `decisions`.

## Applicable Patterns

- **Pipeline Orchestration** (`patterns/pipeline-orchestration.md`): Entity upsert segue o pattern de stages no pipeline. Não commita — o caller gerencia a transação.
- **Data Access** (`patterns/data-access.md`): Entity upsert usa `create_or_update_entity()` que já usa ON CONFLICT DO UPDATE. Fail-safe com try/except.
- **Testing** (`patterns/testing.md`): Testes usam mock session, sem DB. Verificar chamadas via `mock_session` assertions.

## Risks

| Risco | Probabilidade | Mitigação |
|---|---|---|
| Entity upsert adiciona latência ao pipeline (1 DB call por entity) | Baixa | `create_or_update_entity()` é um INSERT ON CONFLICT — operação leve. Pra 15 entities, ~15ms adicionais. Aceitável. |
| Mudança de `int` pra `list` quebra consumidores existentes que fazem `if result.facts_unchanged > 0` | Média | Breaking change intencional. Documentar no changelog. `len(result.facts_unchanged) > 0` é o equivalente. Versão minor bump sinaliza mudança. |
| Testes existentes quebram | Certa | Escopo inclui ajustar todos os testes. São poucos (2-3 assertions). |
