# Spec: Relation Lifecycle — Parte 1: Integridade Fato↔Relação

> PRD: `.vibeflow/prds/relation-lifecycle.md`
> Budget: ≤ 6 arquivos

## Objetivo

Vincular relações aos fatos que as sustentam via `evidence_fact_id` e invalidar relações automaticamente quando o fato de evidência é invalidado, eliminando relações órfãs e contraditórias no knowledge graph.

## Contexto

Hoje `evidence_fact_id` em `MemoryEntityRelationship` é sempre NULL (código morto). Fatos e relações são extraídos em paralelo sem mecanismo de associação. Quando um fato é invalidado (UPDATE/DELETE), a relação sobrevive eternamente — gerando arestas contraditórias que poluem o BFS do graph retrieval. Todos os campos necessários já existem no schema (`evidence_fact_id`, `valid_to`, `invalidated_at`).

## Definition of Done

1. **`upsert_relations()` recebe fatos recém-criados e popula `evidence_fact_id`** — A assinatura aceita um mapeamento `subject → list[MemoryFact]` (dos fatos criados no step 5). Para cada relação, o match heurístico busca um fato correspondente e seta `evidence_fact_id` no INSERT. Relações sem match ficam com `evidence_fact_id = NULL`.
2. **Match heurístico funciona** — Para relação `(source, target)`: busca fatos cujo `entity_key` é `source_entity_key` OU `target_entity_key`, e cujo `fact_text` (lowercase) contém ambos os nomes de entidade (via `entity_map`). Múltiplos candidatos → maior confidence. Zero candidatos → NULL.
3. **`_update_fact()` cascateia invalidação** — Após setar `valid_to`/`invalidated_at` no fato, executa query que invalida todas as relações com `evidence_fact_id = fact.id AND invalidated_at IS NULL`.
4. **`_delete_fact()` cascateia invalidação** — Mesmo comportamento que `_update_fact()` para a cascata.
5. **Novos testes cobrem evidence linkage e cascata** — Testes unitários mock-based (sem DB) para: (a) match heurístico encontra fato correto, (b) match heurístico sem candidato → NULL, (c) cascata UPDATE invalida relação, (d) cascata DELETE invalida relação, (e) cascata não toca relações sem evidence.
6. **Documentação do write pipeline atualizada (EN + PT-BR)** — `docs/{en,pt-BR}/concepts/write-pipeline.md` descreve o evidence linkage e a cascata de invalidação.

## Escopo

- Modificar `src/arandu/write/upsert.py`: adicionar função `_match_relation_to_fact()`, adicionar cascata em `_update_fact()` e `_delete_fact()`, alterar assinatura de `upsert_relations()`.
- Modificar `src/arandu/write/pipeline.py`: coletar fatos criados do step 5 (via `decisions` + `entity_map`), construir mapeamento `entity_key → list[MemoryFact]`, passar para `upsert_relations()`.
- Criar `tests/test_relation_lifecycle.py`: testes para match heurístico e cascata.
- Atualizar `docs/en/concepts/write-pipeline.md` e `docs/pt-BR/concepts/write-pipeline.md`.

## Anti-escopo

- **Não** criar fatos-espelho para relações sem match (isso é Parte 3).
- **Não** alterar `_multi_pass_extract()` nem o paralelismo de extração.
- **Não** implementar `EXCLUSIVE_REL_TYPES` ou exclusividade 1:1.
- **Não** alterar prompts de extração.
- **Não** alterar `models.py` (schema já tem tudo).
- **Não** alterar `graph_retrieval.py` (já filtra `invalidated_at.is_(None)`).
- **Não** fazer backfill de relações existentes sem evidence.
- **Não** alterar `constants.py` ou tipos canônicos.
- **Não** atualizar docs internos `.vibeflow/` (será feito ao final de todas as partes).

## Decisões Técnicas

### DT1: Mapeamento de fatos para `upsert_relations()`

**Decisão**: `upsert_relations()` recebe um parâmetro adicional `created_facts: dict[str, list[MemoryFact]]` — um dicionário `entity_key → [MemoryFact, ...]` dos fatos recém-criados (ações ADD e UPDATE do step 5).

**Trade-off**: Poderia receber `list[ReconciliationDecision]` com `fact_id` preenchido, mas isso exigiria alterar `ReconciliationDecision` e o fluxo de `execute_upsert`. O mapeamento por `entity_key` é mais simples e desacoplado.

**Construção do mapeamento em `pipeline.py`**: Após `execute_upsert()`, iterar sobre `decisions` com ação ADD/UPDATE. Para ADD, o fato foi criado com `session.flush()` (já tem ID) — buscar via query `SELECT ... WHERE entity_key = :key AND source_event_id = :event_id ORDER BY valid_from DESC LIMIT 1`. Para UPDATE, o novo fato também tem ID pós-flush.

**Alternativa mais simples**: Modificar `_add_fact()` e `_update_fact()` para retornar o `MemoryFact` criado, e acumular em `execute_upsert()` retornando `UpsertResult` com campo extra `created_facts: dict[str, list[MemoryFact]]`.

**Decisão final**: Usar a alternativa simples — `_add_fact()` e `_update_fact()` retornam `MemoryFact`, `UpsertResult` ganha campo `created_facts`, pipeline passa `upsert_result.created_facts` para `upsert_relations()`. Menos queries, mais direto.

### DT2: Heurística de match relação→fato

**Decisão**: Função `_match_relation_to_fact(relation, created_facts, entity_map) → UUID | None`.

Algoritmo:
1. Resolver `source_entity_key` e `target_entity_key` via `entity_map`.
2. Buscar fatos em `created_facts[source_entity_key]` + `created_facts[target_entity_key]`.
3. Para cada candidato, verificar se `fact_text.lower()` contém o `display_name.lower()` de AMBOS source e target (ou entity_key).
4. Se múltiplos candidatos, escolher o de maior `confidence`.
5. Se zero candidatos, retornar `None`.

**Trade-off**: Match textual simples pode falhar com pronomes ("ele trabalha lá"). Aceitável — relações sem match ficam com `evidence_fact_id = NULL` e a cascata não se aplica a elas. Parte 3 cria fato-espelho como fallback.

### DT3: Cascata de invalidação

**Decisão**: Função `_cascade_invalidate_relations(session, fact_id, now)` chamada dentro de `_update_fact()` e `_delete_fact()`, APÓS invalidar o fato, DENTRO do mesmo savepoint.

```python
async def _cascade_invalidate_relations(
    session: AsyncSession, fact_id: uuid.UUID, now: datetime
) -> int:
    stmt = (
        select(MemoryEntityRelationship)
        .where(
            MemoryEntityRelationship.evidence_fact_id == fact_id,
            MemoryEntityRelationship.invalidated_at.is_(None),
        )
    )
    result = await session.execute(stmt)
    relations = list(result.scalars().all())
    for rel in relations:
        rel.valid_to = now
        rel.invalidated_at = now
        session.add(rel)
    return len(relations)
```

**Trade-off**: Poderia usar UPDATE em batch via `session.execute(update(...))`, mas iterar e setar mantém consistência com o padrão de data access do projeto (select + modify + add). Performance: ~1 query extra por fato invalidado, impacto negligível.

### DT4: Assinatura atualizada de `_update_fact()` e `_delete_fact()`

**Decisão**: Ambas passam a retornar `MemoryFact | None` (o fato criado para UPDATE, None para DELETE). `_add_fact()` também passa a retornar `MemoryFact`. Isso permite que `execute_upsert()` acumule fatos criados sem queries extras.

`_update_fact()` e `_delete_fact()` chamam `_cascade_invalidate_relations()` internamente. Recebem `session` (já recebem) e usam o `now` que já é parâmetro.

## Applicable Patterns

- **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`): Stages recebem parâmetros explícitos. `pipeline.py` constrói o mapeamento e passa para `upsert_relations()`. Sem shared state.
- **Data Access** (`.vibeflow/patterns/data-access.md`): Cascata usa `session.begin_nested()` (já presente nos callers). `select() + modify + add` para relações. `session.flush()` já garante IDs nos fatos.
- **Error Handling** (`.vibeflow/patterns/error-handling.md`): Cascata é wrapped em try/except com `logger.warning()`. Falha na cascata não impede o resto do pipeline.
- **Testing** (`.vibeflow/patterns/testing.md`): Testes mock-based. `FakeLLMProvider` para extração. `MagicMock(spec=MemoryFact)` para fatos. `MagicMock(spec=AsyncSession)` para session com mocks de `execute()` e `begin_nested()`.

## Riscos

| Risco | Impacto | Mitigação |
|-------|---------|-----------|
| Match heurístico falha com pronomes/linguagem indireta | Relações sem evidence — cascata não se aplica | Aceitável para Parte 1. Parte 3 cria fato-espelho como fallback. |
| Cascata invalida relação que deveria sobreviver (evidence incorreto) | Relação válida perdida | Improvável: match é conservador (ambos nomes devem aparecer no fact_text). False negative > false positive. |
| Performance: query extra de cascata em cada UPDATE/DELETE | Latência do write pipeline | Impacto mínimo: ~1 SELECT por fato invalidado. Fatos são invalidados individualmente, não em batch. |
| `_add_fact()`/`_update_fact()` retornando `MemoryFact` quebra callers existentes | Bug de regressão | Callers existentes ignoram return value (não fazem `_ = await _add_fact(...)`). Adicionar return é backward-compatible. |
