# Decision Log
> Newest first. Updated automatically by the architect agent.

## 2026-04-09 — Deterministic retrieval planner (no LLM in read hot path)

**Context**: The Personal Genius assistant reported that running `retrieve()` 10 times with the same query against fixed memory returned different facts between runs. Root cause: `plan_retrieval()` called an LLM with `temperature=0`, but cloud LLM APIs are not deterministic at temperature=0 (documented behavior — batching, fp16 rounding, GPU routing introduce variation). The LLM produced different `similarity_query` reformulations and `entities` lists across runs, cascading into different semantic searches and graph traversals. An initial fix (v0.12.4) added an `enable_retrieval_planner` opt-out flag — recognized as a remendo by the dev. A centering-theory anaphora resolver was then proposed and rejected as overengineering: anaphora resolution is the caller's responsibility since the caller has the conversation context.

**Decision**: Replace the LLM-powered `plan_retrieval()` with a fully deterministic implementation. The new planner uses: (1) regex for greeting detection (skip strategy), (2) regex + schema prefix lookup for aggregation pattern queries, (3) regex for broad query detection, (4) `similarity_query = query_text` (no reformulation). Entity extraction is NOT done by the planner — `resolve_query_entities()` (deterministic alias/display_name/slug matching) handles it in the pipeline. Anaphora resolution (pronoun → entity) is explicitly documented as the **caller's responsibility** — the caller has the conversation context and should resolve pronouns before calling `retrieve()`.

**Invariant**: `plan_retrieval()` is a pure function of `(agent_id, query_text, schema)`. Same input = same output, always. No LLM, no sampling, no floating-point variance. Tested with 20 back-to-back runs in CI (`test_determinism_same_input_same_output`).

**Trade-off**: Loss of query reformulation (`"onde eu trabalho?"` → `"local de trabalho do usuário"`). Modern embedding models handle natural language queries well enough that reformulation provides marginal improvement. The determinism guarantee is strictly more valuable than marginal retrieval quality from reformulation — especially since the reformulation was the primary source of non-determinism.

**Related**: `.vibeflow/prds/deterministic-anaphora-centering.md` (rejected centering approach), `src/arandu/read/retrieval_agent.py`, `tests/test_retrieval_agent.py`.

## 2026-04-09 — FactQuery as single source of read queries for MemoryFact

**Context**: Between March and April 2026, two production bugs emerged from the same structural gap — 26 query sites against `MemoryFact` across 7 files had no shared abstraction, 25 of them lacked stable `ORDER BY` tiebreakers, and 14 duplicated the base filter (`agent_id + valid_to IS NULL + confidence >= min`) verbatim. First it was `get_all()` pagination instability (fixed cirúrgicamente em `1ce37bf`). Then `retrieve()` non-determinism reported by the Personal Genius assistant (5 identical runs returning different facts). Fixing each cirúrgicamente would be the third patch for the same class of bug. The real problem was the absence of an abstraction that forced tiebreakers and filter reuse.

**Decision**: All read queries against `MemoryFact` in `src/arandu/read/` and `src/arandu/client.py` MUST go through the `FactQuery` builder in `src/arandu/_fact_query.py`. The builder exposes factory methods (`active`, `all_versions`), filter methods, four ordering methods (all embedding `MemoryFact.id` as tiebreaker by construction), pagination, and a terminal `.build()` returning a `Select[MemoryFact]`. There is no raw `.where()` or `.order_by()` escape hatch — the API itself prevents unstable ordering from being constructed.

**Invariant**: `grep "select(MemoryFact[,)])" src/arandu/read/ src/arandu/client.py` returns only whitelisted call sites (column projections and UUID point-lookups). Any new direct select is a regression — the build fails via `tests/test_fact_query_invariant.py`.

**Trade-off**: One level of indirection between the caller and the raw `Select`. Worth it — stable ordering is now impossible to forget, the base filter lives in exactly one place, and future features (backups, audit logs, analytics) can add new filters by extending the builder instead of touching every call site.

**Migration plan**: 4-part refactor documented in `.vibeflow/prds/fact-query-abstraction.md`. Part 1 (builder only) shipped in v0.11.9. Part 2 (read pipeline + client.get_all) shipped in v0.12.0/0.12.1. Part 3 (write/reconcile) shipped in v0.12.2. Part 4 (background jobs: consolidation, clustering, memify, sleep_time) completes the cycle — the invariant now covers the full `src/arandu/` tree. `order_by_importance` added to the builder during Part 4 to support the profile consolidation job.

**Related**: `.vibeflow/specs/fact-query-abstraction-part-1.md`, `.vibeflow/specs/fact-query-abstraction-part-2.md`, `src/arandu/_fact_query.py`, `tests/test_fact_query_invariant.py`.

## 2026-04-09 — Polymorphic input normalization at pipeline entry points

**Context**: The `entity_keys` filter in `retrieve()` and `get_all()` originally required the exact canonical key (`person:pedro_menezes`). Callers naturally tried aliases (`person:pedro`) and got zero results silently — the SDK had a `memory_entity_aliases` table populated by the write pipeline but never consulted it during reads. The "zero silently" behavior was the worst possible DX.

**Decision**: When a public API accepts an input that can be presented in multiple forms (canonical, alias, slug), the SDK resolves it **upstream** — at the entry point of the pipeline, before any SQL filter — and surfaces unresolved keys via a `warnings: list[str]` field on the public return dataclass.

**Pattern to follow**:
1. Create a private helper `_resolve_X(session, agent_id, inputs) -> tuple[list[str], list[str]]` returning `(resolved, invalid)`.
2. Call it exactly once, at the top of the pipeline entry point (e.g., `retrieve_candidates`, `get_all`).
3. Downstream functions receive only the canonical list — no double resolution.
4. Invalid inputs become user-facing strings on the result dataclass: `f"entity_key '{key}' not found (not canonical, no matching alias)"`.
5. Resolution must be fail-safe: if the resolver query errors, degrade to raw inputs + log warning. Never crash the operation.

**Trade-off**: One extra query per filtered request. Acceptable: the tables are indexed, cost is <1ms, and only happens when the filter is used. The DX gain (no more zero silently) is orders of magnitude more valuable than the cost.

**Related**: `.vibeflow/specs/entity-keys-alias-resolution.md`, `.vibeflow/audits/entity-keys-alias-resolution-audit.md`.

## 2026-04-09 — Single entry point for MemoryFact creation in write pipeline

**Context**: v0.11.6 shipped with a parallel fact creation path — `_create_mirror_fact()` instantiated `MemoryFact` directly via `session.add()`, bypassing `reconcile_facts` and `execute_upsert`. Result: three simultaneous bugs (speaker=None, dedup bypassed, language inconsistency) on the same fact, all traceable to the same architectural flaw.

**Decision**: Every `MemoryFact` creation in the write pipeline MUST go through `_add_fact()` (for ADD) or `_update_fact()` (for versioned UPDATE). Synthetic facts — mirror facts, inferred facts, any fallback — must be built as `ExtractedFact` dataclasses and routed through the canonical `reconcile_facts → execute_upsert` pipeline.

**Invariant**: `grep "MemoryFact(" src/arandu/write/` must return at most 2 matches (`_add_fact` and `_update_fact`). Any third match is a regression of the architectural invariant and must be justified or rejected.

**Trade-off**: Adds one extra LLM call (reconcile) per batch of unmatched relations. Small cost vs eliminating an entire class of metadata-propagation bugs. Also forces any future pipeline improvements (new reconciler prompts, better dedup, multilingual handling) to benefit every code path uniformly — no more "which path are we on?" debugging.

**Related**: `.vibeflow/audits/mirror-facts-reconcile-audit.md`, `.vibeflow/specs/mirror-facts-reconcile.md`.

## 2026-03-20 — arandu-server: API Key authentication per tenant

**Context**: Need a simple authentication mechanism for the server that maps requests to users.

**Decision**: API Key per tenant. The server maps API Key → user_id internally. No OAuth, no JWT complexity.

**Trade-off**: Simpler than token-based auth, but API keys are long-lived and need secure storage. Acceptable for MVP — can layer OAuth later if needed.

## 2026-03-20 — arandu-server: OpenAI-only via env vars

**Context**: Deciding whether to build a plugin system for multiple LLM/embedding providers in the server.

**Decision**: Only OpenAI, configured via environment variables. No plugin system. Users who want other providers use the SDK directly (which already supports provider injection).

**Trade-off**: Less flexible server, but drastically simpler. Server is the "batteries included" path; SDK is the "bring your own provider" path.

## 2026-03-20 — arandu-server: tenant-owned database URLs

**Context**: Multi-tenancy database architecture — shared DB vs isolated DBs.

**Decision**: Each tenant passes their own `database_url` in configuration. No shared database. Complete data isolation per tenant.

**Trade-off**: More operational overhead for tenants (they manage their own Postgres+pgvector), but zero data leakage risk and simpler server code (no tenant_id filtering everywhere).

## 2026-03-20 — arandu-server: MCP tools + internal APScheduler

**Context**: Deciding which operations to expose as MCP tools vs run internally.

**Decision**: Expose only `write` and `retrieve` as MCP tools (agent-facing). Background jobs (clustering, consolidation, memify, sleep-time compute) are NOT tools — they run automatically via APScheduler every 4-8h inside arandu-server.

**Trade-off**: Agents can't trigger background jobs on-demand, but this matches the "sleep-time compute" philosophy — these are expensive batch operations, not real-time agent actions.

## 2026-03-19 — Mirror facts: string template over LLM generation

**Context**: Relation lifecycle Part 3 needed to create synthetic facts for relations without a corresponding extracted fact (evidence_fact_id = NULL after heuristic match).

**Decision**: Use a string template `f"{source_name} {rel_type.replace('_', ' ')} {target_name}"` to generate mirror fact text. No LLM call. Facts created with `confidence=0.60`, `importance=0.3`, `source_context="inferred_from_relation"`.

**Trade-off**: Produces generic English phrases regardless of conversation language ("Ana lives in Curitiba" even for Portuguese conversations). Acceptable because: (a) mirror facts are for semantic search via embeddings, not user display, (b) multilingual embedding models capture cross-lingual semantics well, (c) LLM call would add ~3-5s latency per mirror fact.

**Evidence**: `_create_mirror_fact()` in `src/arandu/write/upsert.py` L139-187.

## 2026-03-19 — Free relation extraction: illustrative examples over hard-coded types

**Context**: Relation lifecycle Part 2 needed to remove the canonical type restriction from extraction prompts.

**Decision**: Replace "Allowed types: works_at, manages, ..." with open-ended instructions: "Use a short, descriptive snake_case type" + "Common types:" (illustrative list) + "You may use other types." `normalize_rel_type()` already did pass-through for unknown types — only the docstring and `CANONICAL_REL_TYPES` comment were updated to reflect the new intent.

**Trade-off**: LLM may produce inconsistent type variations. Mitigated by `_REL_TYPE_ALIASES` for common synonyms. Novel types pass through as-is — better to have `mentored_by` in the graph than lose the information.

**Evidence**: Prompts in `src/arandu/write/extract.py` L47-51, L106-109. Docstring in `src/arandu/constants.py` L93-105.

## 2026-03-19 — Evidence linkage: heuristic match over LLM match

**Context**: Relation lifecycle Part 1 needed a mechanism to associate extracted relations with persisted facts (for `evidence_fact_id`).

**Decision**: Use text-based heuristic match (`_match_relation_to_fact`) instead of LLM-based matching. The heuristic checks if `fact_text` mentions both source and target entity `display_name` (case-insensitive), selecting the highest-confidence candidate.

**Trade-off**: Heuristic fails with pronouns/indirect language (~10% of cases). Accepted because: (a) false negatives are safe (relation gets `evidence_fact_id = NULL`, cascade doesn't apply), (b) Part 3 will add mirror facts as fallback, (c) LLM match would add latency and cost per relation.

**Evidence**: Implemented in `src/arandu/write/upsert.py` L85-136.

## 2026-03-19 — Cascade invalidation: select+modify over batch UPDATE

**Context**: When a fact is invalidated, relations referencing it via `evidence_fact_id` must also be invalidated.

**Decision**: Use `select() → iterate → modify → session.add()` pattern instead of batch `UPDATE ... WHERE` SQL. This is consistent with the project's data access pattern (all other update operations in upsert.py use select+modify+add).

**Trade-off**: Slightly less efficient than a single UPDATE statement, but consistent with the codebase and simpler to test. Performance impact is negligible (~1 extra query per invalidated fact).

**Evidence**: Implemented in `src/arandu/write/upsert.py` L48-82.

## 2026-03-19 — created_facts on UpsertResult over separate query

**Context**: `upsert_relations()` needs recently created facts to do evidence matching. Two options: (a) query DB after `execute_upsert`, (b) have `_add_fact`/`_update_fact` return the created fact and accumulate in `UpsertResult`.

**Decision**: Option (b) — `_add_fact()` and `_update_fact()` return the `MemoryFact` they create. `execute_upsert()` accumulates them in `UpsertResult.created_facts: dict[str, list[MemoryFact]]`. No extra DB queries needed.

**Trade-off**: Adds a return value to private functions that previously returned None. Backward-compatible because no caller checked the return value.

**Evidence**: `UpsertResult.created_facts` field at L35. Accumulation at L336, L350.
