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

## 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.
