# Spec: Alias Resolution no entity_keys + Warnings

> Source PRD: `.vibeflow/prds/entity-keys-alias-resolution.md`
> Generated: 2026-04-09

## Objective

Fazer o filtro `entity_keys` resolver aliases automaticamente e expor `warnings` no `RetrieveResult`, eliminando o zero silencioso quando o consumidor passa uma key que bate como alias em vez da canonical.

## Context

Hoje o filtro `entity_keys` aplica `MemoryFactEntityLink.entity_key.in_(entity_keys)` direto no SQL, sem consultar a tabela `memory_entity_aliases` (que já é populada pelo write pipeline via `register_entity_alias`). Se o dev passa `entity_keys=["person:pedro"]` mas a canonical é `person:pedro_menezes`, o retrieve retorna vazio sem nenhum sinal de erro — o consumidor acha que não existe dado sobre a entidade.

Isso quebra de duas formas:

1. **Incoerência write↔read.** O write resolve alias (pronouns, fuzzy match, grafo). O read ignora. O dev razoavelmente espera que `agent_id="pedro"` (permissivo) e `entity_keys=["person:pedro"]` (rígido) se comportem consistentemente. Não se comportam.

2. **Zero silencioso.** Mesmo que o dev eventualmente aprendesse a passar canonical, um typo ou key desatualizada (ex: curadoria de contexto com vários hints) continua retornando vazio sem warning. O pior comportamento possível pra uma API pública.

O Personal Genius (assistente do Pedro via MCP) reproduziu ambos os cenários em produção. É um bloqueador prático pra uso de `entity_keys` em qualquer integração real.

## Definition of Done

1. `retrieve(agent_id, query, entity_keys=["person:pedro"])` retorna os mesmos fatos que `entity_keys=["person:pedro_menezes"]` quando "person:pedro" é alias registrado pra "person:pedro_menezes" (ou quando apenas "pedro" é alias).
2. `get_all(agent_id, entity_keys=["person:pedro"])` também resolve alias, mesmo comportamento.
3. `RetrieveResult` tem novo campo `warnings: list[str]`, populado quando qualquer key passada não resolve nem como canonical nem como alias.
4. Comportamento parcial: `entity_keys=["person:pedro", "person:xyz"]` filtra pelos válidos (`person:pedro_menezes`) e adiciona warning só pra `person:xyz`. Não aborta.
5. Sem `entity_keys` (`None`), zero queries novas são feitas contra `memory_entity_aliases`/`memory_entities` — overhead do fix só acontece quando o filtro é usado.
6. **Quality gate:** resolução é feita por **um único helper** (`_resolve_entity_keys`) chamado uma vez upstream em `retrieve_candidates` e `get_all`. `semantic_search` e `keyword_search` recebem a lista já resolvida — não há lookup de alias duplicado em múltiplos lugares.
7. Docs atualizados em `docs/en/` e `docs/pt-BR/` (getting-started + personal-assistant): exemplo mostrando que alias funciona + menção ao campo `warnings`.

## Scope

### Código

- **`src/arandu/read/retrieval.py`**:
  - Novo helper `async def _resolve_entity_keys(session, agent_id, entity_keys) -> tuple[list[str], list[str]]` — retorna `(resolved, invalid)`.
    - Query 1: `SELECT canonical_key FROM memory_entities WHERE agent_id = ? AND canonical_key IN (?)` — pega as que já são canonical.
    - Query 2: `SELECT alias, canonical_entity_key FROM memory_entity_aliases WHERE agent_id = ? AND alias IN (?)` — onde `?` é a lista inteira (chaves que não bateram como canonical passam a ser tratadas como possíveis aliases).
    - **Normalização de lookup**: pra cada chave em `entity_keys`, tentar match tanto pela chave inteira (`"person:pedro"`) quanto pelo "slug" depois do `:` (`"pedro"`), já que aliases na tabela são armazenados em forma normalizada. Primeiro tenta canonical → se não achar, tenta alias → se não achar nada, vai pro `invalid`.
    - Retorno: `(resolved_keys, invalid_keys)` onde `resolved_keys` é lista deterministicamente ordenada das canonical keys encontradas (sem duplicatas), e `invalid_keys` é lista das originais que não resolveram.
  - `retrieve_candidates()` passa a receber `entity_keys` original, chamar `_resolve_entity_keys` antes, e propagar **só as resolved** para `semantic_search`/`keyword_search`. O `invalid_keys` vira parte do retorno (ver item abaixo).
  - `retrieve_candidates()` retorna agora `tuple[list[RetrievalCandidate], list[str]]` — candidates + invalid_keys (pra virar warnings upstream). Assinatura muda, então chamadores precisam atualizar.
  - `semantic_search` e `keyword_search` **não mudam de assinatura** — continuam recebendo `entity_keys: list[str] | None`. A diferença é semântica: agora a lista recebida já é resolved (canonical only). Filtro SQL atual continua funcionando sem mudança.
  - Se `_resolve_entity_keys` retorna `resolved_keys=[]` e `invalid_keys=[...]`, `retrieve_candidates` **ainda faz a busca com filtro vazio** (lista vazia no `IN (...)` não bate com nada) — resultado: zero candidates. Consistente com a regra "comportamento parcial, nunca ignora o filtro".

- **`src/arandu/read/pipeline.py`**:
  - `ReadResult` ganha campo `warnings: list[str] = field(default_factory=list)`.
  - `run_read_pipeline()` recebe as `invalid_keys` de `retrieve_candidates` e monta warnings como strings no formato: `f"entity_key '{key}' not found (not canonical, no matching alias)"`. Adiciona ao `ReadResult.warnings`.
  - Propaga `warnings` no `ReadResult` final retornado.

- **`src/arandu/client.py`**:
  - `RetrieveResult` ganha campo `warnings: list[str] = field(default_factory=list)`.
  - `MemoryClient.retrieve()` mapeia `ReadResult.warnings` para `RetrieveResult.warnings` na conversão final.
  - `MemoryClient.get_all()` chama o mesmo helper `_resolve_entity_keys` (import direto de `retrieval.py` ou helper duplicado — ver decisão técnica). Aplica o filtro só com as resolved. `get_all()` **não** retorna warnings (seu retorno é `list[FactDetail]`, não dataclass). Se quiser warnings no CRUD, é out of scope.

- **`pyproject.toml`** + **`src/arandu/__init__.py`**: bump patch.

### Testes

- **`tests/test_retrieve_entity_filter.py`** (arquivo já existe, só adicionar testes):
  - `test_alias_resolves_to_canonical` — alias puro vira canonical no filter SQL.
  - `test_canonical_key_direct_still_works` — chave canonical passa direto sem warning.
  - `test_partial_mix_valid_invalid` — mistura filtra pelos válidos + warning pros inválidos.
  - `test_all_invalid_returns_empty_with_warnings` — todas inválidas → `facts=[]`, `warnings=[...]`.
  - `test_no_entity_keys_zero_alias_queries` — mock da session verifica que quando `entity_keys is None`, a query de aliases **nunca é feita**.
  - `test_get_all_resolves_alias` — `get_all()` também resolve.

### Docs

- **`docs/en/getting-started.md`** + **`docs/pt-BR/getting-started.md`**:
  - Na seção "Managing Facts" → "Filtering by entity" (já existe), adicionar nota: `entity_keys` aceita aliases. Passa `"person:pedro"` e ele resolve via `memory_entity_aliases` se a canonical for outra. Chaves não encontradas entram em `result.warnings`.
  - Pequeno exemplo: `entity_keys=["person:pedro", "person:unknown"]` → filtra pelo pedro + warning pra unknown.

- **`docs/en/personal-assistant.md`** + **`docs/pt-BR/personal-assistant.md`**:
  - No exemplo `retrieve(... entity_keys=["person:ana"])`, adicionar nota explicando que o alias vai ser resolvido automaticamente e que `result.warnings` sinaliza keys desconhecidas.

## Anti-scope

- **Warnings no `WriteResult`** — `facts_added/updated/deleted/unchanged` já dão sinal suficiente no write. Adiciona só se virar caso real.
- **Warnings no `get_all`** — retorno é `list[FactDetail]`, não dataclass. Adicionar warning quebraria signature e é out of scope. `get_all` resolve alias e filtra, mas inválidos são silenciosamente ignorados no CRUD (aceitável porque CRUD é lookup direto, não curadoria de contexto).
- **Warnings estruturados** (dict/enum) — string livre v0. Consumidor consome direto.
- **Fuzzy match / typo correction** — se passar `"persn:pedro"`, vai pro `invalid`, não tenta adivinhar.
- **Lookup por `display_name`** (`entity_keys=["Pedro"]` via nome humano) — spec separada se virar dor. Escopo atual é expandir alias→canonical, não fazer NER.
- **Cache de resolução** — sem cache. Uma query extra por retrieve é aceitável (tabela indexada, `< 1ms`).
- **Resolução por similaridade de embedding** — zero. Se não é canonical nem alias literal, é inválido.
- **Retornar `entity_keys` resolvidas junto com o resultado** (`result.entity_keys_resolved`) — nice-to-have, fora do escopo. Warnings explicitam o que foi ignorado; isso é suficiente.
- **Backward compat pra `retrieve_candidates`** — a função é **interna** do pipeline (não exportada no `__init__.py`). Mudar assinatura dela sem deprecation é aceitável.

## Technical Decisions

### 1. Helper único de resolução em `retrieval.py`

**Decisão:** `_resolve_entity_keys` vive em `src/arandu/read/retrieval.py` como private helper. `client.py::get_all` importa direto de lá.

**Trade-off:** Ter helper em `retrieval.py` (módulo do read pipeline) cria import cross-module do client pro read. Alternativa seria criar `src/arandu/_entity_resolution.py` neutro. Mas: já existe outro `entity_resolution.py` (em `write/`), nome conflita. E o helper é pequeno (~20 linhas), não justifica módulo novo.

**Justificativa:** Simplicidade. Um helper, um lugar, dois chamadores (pipeline interno + client public). Import cross-module é normal no código existente (`client.py` já importa de `read/pipeline`).

### 2. Resolução upstream (antes de `semantic_search`/`keyword_search`)

**Decisão:** `_resolve_entity_keys` é chamado **uma vez** em `retrieve_candidates`, antes de disparar semantic + keyword em paralelo. A lista resolvida é passada pra ambas as funções.

**Trade-off:** Alternativa era resolver dentro de cada função de search. Duplica queries.

**Justificativa:** Uma única query pra aliases, mesmo que sejam duas searches downstream. Satisfaz DoD #6.

### 3. Normalização de lookup — "tenta full-key e tenta slug"

**Decisão:** `_resolve_entity_keys` tenta duas formas de match pro alias: `entity_keys` passado intacto (ex: `"person:pedro"`) **e** a parte depois do `:` (`"pedro"`). O write pipeline tanto registra aliases como "pedro" (string crua) quanto "pedro menezes" etc — tem que cobrir os dois formatos.

**Trade-off:** Dobra a lista de aliases candidatas na query. Cost neutro (tabela indexada).

**Justificativa:** O dev pode passar `"person:pedro"` ou `"pedro"` esperando que funcione. Ambos devem resolver pra `person:pedro_menezes`. Se só tentar um formato, o outro quebra.

### 4. `retrieve_candidates` muda assinatura de retorno

**Decisão:** Retorna `tuple[list[RetrievalCandidate], list[str]]` em vez de só `list[RetrievalCandidate]`. Chamadores (só `run_read_pipeline` internamente) são atualizados.

**Trade-off:** Quebra de API interna. Alternativa seria passar uma lista mutável pra capturar warnings — mais feio.

**Justificativa:** Tuple return é Pythonic, a função é interna (não exportada), e o invalid_keys é uma saída natural da resolução.

### 5. Warnings são strings livres no formato fixo

**Decisão:** Formato canônico: `f"entity_key '{key}' not found (not canonical, no matching alias)"`. Uma string por key inválida.

**Trade-off:** Formato fixo facilita consumo mas impede parsing estruturado sem regex.

**Justificativa:** PRD decidiu string livre. Formato consistente é uma convenção interna que facilita eventual parsing futuro. Se virar dor, evolui pra dataclass com backward compat.

### 6. Fail-safe: se resolução der erro, cai no comportamento antigo

**Decisão:** `_resolve_entity_keys` é envolvido em try/except. Se a query falhar (connection error, etc), retorna `(entity_keys, [])` — usa as chaves originais sem resolver e sem warnings. Log warning interno.

**Trade-off:** Pode mascarar bugs no helper. Mas evita que um bug de resolução derrube o retrieve inteiro.

**Justificativa:** Pattern de error handling do projeto é fail-safe. Retrieve nunca deve crashar por causa de uma feature acessória.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): queries novas usam `select(...).where(...)` async. Uma query pra canonical validation + uma pra alias lookup. Ambas pequenas e indexadas.
- **Error Handling** (`.vibeflow/patterns/error-handling.md`): fail-safe no helper de resolução. `try/except` ao redor da resolução, fallback pra comportamento legacy + log warning.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based. `AsyncMock` pra session, respostas canonizadas da query de aliases. Class-based grouping no `test_retrieve_entity_filter.py` (já existe).
- **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`): resolução acontece no ponto de entrada do retrieve (`retrieve_candidates`), não em cada stage. Mantém o pipeline linear.

## Risks

1. **Performance do lookup de alias** — Duas queries extras por `retrieve()` quando `entity_keys` é passado. Mitigação: ambas indexadas (`ix_entity_alias_user_lookup` e `uq_memory_entity_user_key` existem nos models). Custo esperado: < 1ms em Postgres local. Zero overhead quando `entity_keys is None`.

2. **Quebra de API interna em `retrieve_candidates`** — mudar retorno pra tuple pode quebrar testes existentes que chamam direto. Mitigação: a função é privada do pipeline; o único call site é `run_read_pipeline`. Os testes existentes que mocam são pra `semantic_search`/`keyword_search`, não pra `retrieve_candidates`.

3. **Formato de alias armazenado diferente do esperado** — Se o write armazena alias como `"pedro"` (sem prefix) mas o dev passa `"person:pedro"` (com prefix), o matching naive falha. Mitigação: lookup "tenta full-key e tenta slug" resolve ambos os cenários. Testes devem cobrir os dois formatos.

4. **Race condition alias→canonical** — Se um alias é registrado entre a query de resolução e a query de filter, o retrieve pode perder um fato recém-criado. Mitigação: aceita. O SDK não é read-after-write consistente entre operações distintas, isso já é verdade em outros pontos.

5. **String livre em warnings dificulta parsing futuro** — Se depois quisermos dict estruturado, precisa de migração. Mitigação: formato canônico permite regex parser simples até migração.

6. **Confusão entre `invalid_keys` e "keys com zero match"** — Uma canonical key válida mas sem fatos linkados retorna `facts=[]` sem warning (porque a key existe, só não tem dados). Comportamento correto mas pode confundir. Mitigação: docs explicitam: "warning = key não existe; facts vazios = key existe mas sem matches".
