# ADR — Corporate Memory V1 must-fix changes **Status:** Accepted **Date:** 2026-04-25 **Branch:** `pabu/local-dev` This ADR records the architectural decisions made to address the three high-severity findings raised before V1 of the corporate-memory feature ships. V1.5 follow-ups are tracked separately in `docs/TODO.md`. --- ## Change set overview | Fix | Files | Reason | |-----|-------|--------| | 1. `is_personal` privacy enforcement | `app/api/memory.py`, `src/repositories/knowledge.py`, `tests/test_memory_api.py` | Personal items were leaking through three vectors. | | 2. Wire contradiction detection + fix candidate SQL | `services/verification_detector/detector.py`, `src/repositories/knowledge.py`, `tests/test_corporate_memory_v1.py` | The feature shipped inert; the SQL pre-filter was structurally broken. | | 3. Stop trusting LLM-supplied confidence + persist evidence | `services/verification_detector/{schemas,prompts,detector}.py`, `src/db.py`, `src/repositories/knowledge.py`, `tests/test_corporate_memory_v1.py` | The LLM should not set its own credibility, and the most valuable signal (the user quote) was being discarded. | Schema bumped from v8 to v10 (v9: audience column; v10: users.groups column) (`SCHEMA_VERSION = 10` in `src/db.py`) — v9 adds the `verification_evidence` table and `knowledge_items.audience` column; v10 adds the `users.groups` JSON column. --- ## Decision 1 — `is_personal` is a hard privacy boundary, not a UI hint ### Context Five paths interacted with `is_personal`: 1. `GET /api/memory` — accepted caller-controlled `exclude_personal` (default `True`) with no role check. Any authenticated user could pass `exclude_personal=false` and read everyone's personal items. 2. `repo.search()` — ignored `is_personal` entirely. `GET /api/memory?search=…` bypassed the filter unconditionally. 3. `GET /{id}/provenance` — no per-item privacy check. 4. `POST /{id}/vote` — no per-item privacy check. 5. `POST /{id}/personal` — already correctly gated to the contributor. The toggle UI presents `is_personal` as "make this item private to me", so treating it as a UI convenience would have been a confidentiality breach. ### Decision Treat `is_personal` as an authorization rule enforced at every read site. - `KnowledgeRepository.search()` gains an `exclude_personal` parameter and appends `AND (is_personal = FALSE OR is_personal IS NULL)` when set. - `app/api/memory.list_knowledge` derives `effective_exclude_personal`: - Privileged viewers (`km_admin`, `admin`) — caller's choice respected. - Everyone else — silently coerced to `True`. The query parameter is intentionally not rejected with 403 to avoid encouraging client-side probing for the role boundary. - A shared `_can_view_item(user, item)` helper centralizes the rule (`not is_personal OR contributor OR privileged_viewer`) and is invoked from `/{id}/provenance` and `/{id}/vote`. - Denied access returns **404, not 403**. The motivation is existence- hiding: a 403 response would let an attacker enumerate item IDs and learn which ones exist as personal items belonging to others. - Contributors continue to see their own personal items via `/api/memory/my-contributions`, which already has the correct semantics. ### Alternatives considered - **Reject `exclude_personal=false` from non-admins with 403.** Simpler to log, but advertises the role boundary. The silent-coerce path matches the principle "the API behaves the same regardless of whether private data exists." - **Strip `is_personal` from query/response entirely for non-admins.** Too destructive — admins still need the toggle and the indicator. Centralizing on `_can_view_item` keeps the mental model uniform. ### Trade-offs - 404-vs-403 is a deliberate UX cost: a contributor who mistypes a URL gets the same 404 as an attacker, with no hint that the issue is auth. The existence-leak risk dominates. - The repo `search()` signature changed (added one parameter with a default of `False`). Existing callers need not change, but the API layer now always passes the effective flag. --- ## Decision 2 — Contradiction detection runs inline; SQL pre-filter is structured ### Context `services/corporate_memory/contradiction.detect_and_record()` exists, but `services/verification_detector/detector.run()` never called it. Items were being created and the contradictions table was always empty. Combined with: - `find_contradiction_candidates` in `src/repositories/knowledge.py` joined domain and keyword conditions with `OR`. Combined with `ORDER BY updated_at DESC LIMIT 10`, recent same-domain noise crowded out genuine conflicts and cross-domain conflicts were treated as same-domain noise. The feature was effectively a stub. ### Decision 1. **Wire the call inline.** After `repo.create(...)` succeeds in `detector.run()`, the new item is reloaded and passed to `contradiction.detect_and_record(extractor, new_item, repo)`. - Wrapped in `try/except` so an LLM judge failure does not abort the session — the verification still lands, the contradiction simply isn't recorded for that item. - A new `contradictions_recorded` counter is added to `stats`. 2. **Fix the SQL pre-filter.** `find_contradiction_candidates` now treats `domain` as a top-level conjunct and `keywords` as an inner OR group: - Both supplied → `domain = ? AND (keyword OR keyword OR …)` - Domain only → `domain = ?` - Keywords only → `(keyword OR keyword)` (cross-domain fallback) - Neither supplied → return empty list. ### Alternatives considered - **Enqueue contradiction checks for a nightly batch job.** This is the V2 plan (Anthropic Batch API, 50 % cost reduction), tracked in `docs/TODO.md`. Inline is the right V1 choice: detector volume is low, governance is high-priority, and admin queues need fresh signal. - **Keep keyword-only candidate matching.** Rejected — the original bug meant cross-domain hits were treated as conflicts (e.g. a "data pipeline churn" doc would conflict with a "finance churn metric"). Adding cross-domain fallback only when domain is missing preserves recall without re-introducing the bug. ### Trade-offs - Each new verification now triggers up to 10 extra LLM calls (one per candidate). At V1 session volume this is acceptable; if it bites, the V2 batch executor (`docs/TODO.md`) is the upgrade path. - The detector loop is now tighter coupled to the corporate-memory module via direct import. This is fine for now — both modules are in the same service tier — but if a future split is needed, the call would move behind a queue. --- ## Decision 3 — Confidence is computed in code; user_quote is persistent evidence ### Context `services/verification_detector/schemas.py` required `base_confidence` from the LLM, and `detector.py` stored it directly into `knowledge_items.confidence`. The handcrafted `compute_confidence(...)` table existed but was bypassed. Two failure modes follow: 1. **Trust:** The LLM (or a prompt injection inside a session) can elevate confidence at will, undermining the entire governance flywheel. 2. **Calibration:** Hardcoded numbers in `_BASE_CONFIDENCE` cannot be tuned without a deploy, and the raw evidence (the user's exact quote that triggered the verification) was extracted by the LLM but immediately discarded. ### Decision 1. **Drop `base_confidence` from the schema.** Removed from `VERIFICATION_SCHEMA` properties and `required`. The prompt no longer asks the LLM for it. 2. **Compute confidence in code.** `confidence = compute_confidence("user_verification", v["detection_type"])`. If `detection_type` is unknown (e.g. an LLM hallucinated a new value), we fall back to the `confirmation` baseline rather than the LLM's number. 3. **Persist evidence.** New table `verification_evidence`: ```sql CREATE TABLE verification_evidence ( id VARCHAR PRIMARY KEY, item_id VARCHAR NOT NULL, source_user VARCHAR, source_ref VARCHAR, detection_type VARCHAR, user_quote TEXT, created_at TIMESTAMP DEFAULT current_timestamp ) ``` Indexed on `item_id`. Multiple rows per item are expected — one per distinct verification event. Repository methods: `create_evidence(...)` and `list_evidence(item_id)`. 4. **Schema version bump.** `SCHEMA_VERSION = 9`. Added `_V8_TO_V9_MIGRATIONS` following the existing migration pattern; the table is also added to the fresh-install `_SYSTEM_SCHEMA` block. ### Alternatives considered - **Keep `base_confidence` in the schema but ignore it on read.** Rejected — it would still consume LLM tokens and invite regressions where someone later "improves" the pipeline by reading the field. - **Store evidence inline on `knowledge_items` (e.g. `last_user_quote` TEXT).** Rejected — multiple analysts independently verifying the same item over time is exactly the signal we want to accumulate. A 1:N table is the right shape. - **Compute confidence as a derived view over `verification_evidence`.** Tempting but premature — the `apply_decay()`, modifier, and per-source floor logic make this non-trivial. Stored materialized value with a recompute job (V1.5, `docs/TODO.md`) is the staged plan. ### Trade-offs - `_BASE_CONFIDENCE` is still hardcoded. Externalizing it to `instance.yaml` is a CLAUDE.md violation we are knowingly carrying through V1. It is the first item in the V1.5 plan (`docs/TODO.md` § Q3) and should land before any production rollout beyond a single instance. - Evidence rows accumulate without bounded retention. At V1 volumes this is fine; once a re-confidence job is added (V1.5), retention can piggy- back on that pipeline. --- ## Decisions explicitly deferred (NOT made in this change) These were proposed in the review but pushed to V1.5 because they are either non-blocking or require design work that should not delay the must-fix bundle. Each is tracked in `docs/TODO.md`. - **Explicit duplicate-candidate hints** (`knowledge_item_relations` table, entity-overlap detection, admin UI surface). - **Embedding pre-filter** for contradiction candidates. - **Anthropic Batch API executor** for nightly contradiction sweeps. - **Externalized confidence config** in `instance.yaml`, exponential decay with per-source floors, per-domain decay policy. - **Bayesian prior calibration** from admin actions, with random-sample holdout. - **Typed entity registry** with canonical IDs, word-boundary regex, hierarchical `parent_id`. - **Multi-evidence boost re-computation** on `verification_evidence` insert. --- ## Schema v9 → v10: Audience Distribution (`users.groups`) **Change:** Added `groups JSON` column to the `users` table. **Purpose:** Enables audience-based knowledge distribution. `knowledge_items.audience` (added in v9) restricts item visibility to specific groups. `users.groups` stores the list of Agnes groups a user belongs to (e.g. `["finance", "engineering"]`), resolved at login from the identity provider. **Migration:** `ALTER TABLE users ADD COLUMN IF NOT EXISTS groups JSON` — runs automatically at startup via `_ensure_schema()`. **Privacy note:** The `user_groups` filter in `KnowledgeRepository.list_items()` and `search()` applies an `audience IN (...)` predicate at the SQL layer. Items with `audience IS NULL` or `audience = 'all'` are visible to everyone; items with a group audience require the caller to be a member of that group. Admin callers bypass this filter entirely. **Follow-up:** The current implementation stores raw group strings in `users.groups`. A future Agnes Groups Mapping UI (tracked in GitHub Issues) will replace this with a first-class Agnes roles registry, where admins map identity-provider groups to Agnes roles. --- ## Decision 4 — Topics + judgment + resolution as one Haiku structured call ### Context Decision 2 wired `detect_and_record(...)` into the detector and fixed the `OR → AND` SQL bug, but kept the underlying architecture: a SQL keyword pre-filter (`title.split()` words >3 chars matched via `ILIKE`) followed by N sequential LLM judge calls (one per candidate). Two structural problems with that design carried over: 1. **Recall holes in the SQL pre-filter.** Synonyms, paraphrases, language variants, and metric aliases miss. "Churn", "attrition", and "logo churn" are the same concept; the keyword filter would treat them as unrelated. 2. **N sequential calls.** Each new verification fired up to 10 Haiku calls. Latency and cost both grow linearly with the candidate cap. Anthropic's Structured Outputs feature is now GA on Haiku 4.5, with strict schema enforcement (required fields guaranteed, types guaranteed, `additionalProperties: false` honored). This makes a single batched structured-output call reliable enough to replace both the keyword filter and the per-candidate loop. ### Decision Replace the keyword SQL pre-filter and the N-call judge loop with **one batched Haiku call** that returns: - a judgment per same-domain candidate (`is_contradiction`, `severity`, `explanation`), - and a structured **resolution** suggestion in the same response (`resolution_action` ∈ `{kept_a, kept_b, merge, both_valid}`, with `resolution_merged_content` populated only when `merge`, plus `resolution_justification`). Concretely: - `services/corporate_memory/prompts.py` adds `BATCH_CONTRADICTION_PROMPT` + `BATCH_CONTRADICTION_SCHEMA`. Resolution is **flattened** into the judgment object (`resolution_action`, `resolution_merged_content`, `resolution_justification`) rather than nested, to stay inside the strict-output constraints (no recursion, predictable required fields). - `services/corporate_memory/contradiction.py`'s `find_and_judge(...)` loads same-domain candidates from SQL and runs **one** Haiku call. `detect_and_record(...)` persists the resulting records. - `src/repositories/knowledge.py`: - `find_contradiction_candidates` is reduced to a domain-only SELECT. The `title_words` parameter is removed entirely. Domain remains a hard SQL conjunct (cheap; bounds prompt size as the corpus grows). - `create_contradiction(suggested_resolution=…)` now accepts either a dict (JSON-encoded into the existing TEXT column) or a string (legacy/free-form). `list_contradictions` and `get_contradiction` decode JSON-shaped values back into dicts on read. ### Defenses against LLM failure modes - **Hallucinated candidate IDs**: every returned `candidate_id` is validated against the input set; rows with unknown IDs are dropped. - **Out-of-enum severity**: normalized to `None` before persistence (the schema enum should already block this, but we don't trust it). - **Out-of-enum resolution_action**: dropped from the persisted record. The contradiction itself stays — only the resolution suggestion is discarded. - **Empty corpus**: short-circuits before any LLM call (cost guard). - **LLM error**: caught and logged; record creation simply yields no contradictions for that item, the verification still lands, the session is still marked processed. ### Alternatives considered - **Keep N sequential calls but switch them to structured output.** Half the gain. Latency and per-item cost stay linear in candidate count. - **No SQL at all — pass the entire `knowledge_items` corpus to Haiku.** Rejected — token cost grows with the corpus size and gets unbounded for large instances. Domain filter is a cheap hard ACL. - **Per-domain shards via embedding cosine + Haiku judge.** Tracked as a future V2 refinement when a domain corpus exceeds ~100 items (`docs/TODO.md`). Not needed for current scale. - **Nested resolution object in the schema.** Tested but flattened — Anthropic's strict structured output works most reliably with no nested optional objects; required-fields-with-null is more reproducible than `anyOf`/`oneOf`. ### Trade-offs - **Fail-open on the LLM.** If Haiku says "no contradiction" for everything, contradictions silently never surface. The previous SQL keyword filter at least seeded obvious matches. Mitigation (deferred): log + alert when a busy domain shows zero contradictions for N consecutive new items. - **No textual fallback.** When the LLM is down, no contradictions are detected at all. Acceptable because the judge was already LLM-dependent — the only thing additionally lost is the candidate seed. - **Prompt size growth.** Prompt scales with same-domain corpus size. At ~100 items × ~50 tokens each the input is ~5k tokens — still cheap on Haiku 4.5. Sharding kicks in via `DEFAULT_CANDIDATE_LIMIT = 100` if a domain blows past that. - **Test rewrites.** All contradiction-side tests previously asserted on the legacy single-pair `{contradicts, …}` schema and `title_words` parameter. They were rewritten to the batched `{judgments: [{candidate_id, is_contradiction, severity, …}]}` shape. ### Schema-side impact None. `knowledge_contradictions.suggested_resolution` is already TEXT; JSON-encoded dicts fit there without a migration. --- ## Verification Tests covering each decision live in: - Decision 1: `tests/test_memory_api.py::TestPersonalItemPrivacy` - Decision 2: `tests/test_corporate_memory_v1.py::TestContradictionCandidateSqlNarrowing`, `TestDetectorWiresContradictionDetection` - Decision 3: `tests/test_corporate_memory_v1.py::TestDetectorIgnoresLLMConfidence`, `TestDetectorPersistsEvidence` - Decision 4: `tests/test_corporate_memory_v1.py::TestContradictionDetectionIntegration`, `TestBatchedContradictionFindAndJudge` (single-call cost guarantee, hallucinated-id defense, mixed batch, severity normalization, resolution round-trip, legacy-string back-compat) Run: ```bash pytest tests/test_memory_api.py tests/test_corporate_memory_v1.py -v ```