Adds corporate memory v1 (verification flywheel + contradiction detection + confidence scoring) and v1.5 (audience-based distribution + per-item privacy + admin curation). Server: GET /api/memory/bundle returns mandatory + ranked-approved items within a token budget; POST /api/memory/admin/mandate accepts an audience field gated against user_group_members; /api/memory/stats uses SQL aggregation. CLI: da sync writes received items to .claude/rules/km_*.md. Verification detector extracts knowledge candidates from session JSONL files. Auto-tagging via Haiku when ai: is configured. Adapted from the v9-era branch onto v13/v14 RBAC: _is_privileged_viewer + _effective_groups now query user_group_members JOIN user_groups; require_role(Role.KM_ADMIN) replaced with require_admin (km_admin collapsed into admin). Schema v15: knowledge_items context-engineering columns + knowledge_contradictions + session_extraction_state. Schema v16: verification_evidence. Cuts release v0.15.0 (also bundles #116 /me/debug page).
18 KiB
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:
GET /api/memory— accepted caller-controlledexclude_personal(defaultTrue) with no role check. Any authenticated user could passexclude_personal=falseand read everyone's personal items.repo.search()— ignoredis_personalentirely.GET /api/memory?search=…bypassed the filter unconditionally.GET /{id}/provenance— no per-item privacy check.POST /{id}/vote— no per-item privacy check.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 anexclude_personalparameter and appendsAND (is_personal = FALSE OR is_personal IS NULL)when set.app/api/memory.list_knowledgederiveseffective_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.
- Privileged viewers (
- A shared
_can_view_item(user, item)helper centralizes the rule (not is_personal OR contributor OR privileged_viewer) and is invoked from/{id}/provenanceand/{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=falsefrom 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_personalfrom query/response entirely for non-admins. Too destructive — admins still need the toggle and the indicator. Centralizing on_can_view_itemkeeps 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 ofFalse). 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_candidatesinsrc/repositories/knowledge.pyjoined domain and keyword conditions withOR. Combined withORDER 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
- Wire the call inline. After
repo.create(...)succeeds indetector.run(), the new item is reloaded and passed tocontradiction.detect_and_record(extractor, new_item, repo).- Wrapped in
try/exceptso 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_recordedcounter is added tostats.
- Wrapped in
- Fix the SQL pre-filter.
find_contradiction_candidatesnow treatsdomainas a top-level conjunct andkeywordsas 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.
- Both supplied →
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:
- Trust: The LLM (or a prompt injection inside a session) can elevate confidence at will, undermining the entire governance flywheel.
- Calibration: Hardcoded numbers in
_BASE_CONFIDENCEcannot 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
- Drop
base_confidencefrom the schema. Removed fromVERIFICATION_SCHEMAproperties andrequired. The prompt no longer asks the LLM for it. - Compute confidence in code.
confidence = compute_confidence("user_verification", v["detection_type"]). Ifdetection_typeis unknown (e.g. an LLM hallucinated a new value), we fall back to theconfirmationbaseline rather than the LLM's number. - Persist evidence. New table
verification_evidence:
Indexed onCREATE 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 )item_id. Multiple rows per item are expected — one per distinct verification event. Repository methods:create_evidence(...)andlist_evidence(item_id). - Schema version bump.
SCHEMA_VERSION = 9. Added_V8_TO_V9_MIGRATIONSfollowing the existing migration pattern; the table is also added to the fresh-install_SYSTEM_SCHEMAblock.
Alternatives considered
- Keep
base_confidencein 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_quoteTEXT). 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 — theapply_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_CONFIDENCEis still hardcoded. Externalizing it toinstance.yamlis 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_relationstable, 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_evidenceinsert.
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:
- 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.
- 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}, withresolution_merged_contentpopulated only whenmerge, plusresolution_justification).
Concretely:
services/corporate_memory/prompts.pyaddsBATCH_CONTRADICTION_PROMPTBATCH_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'sfind_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_candidatesis reduced to a domain-only SELECT. Thetitle_wordsparameter 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_contradictionsandget_contradictiondecode JSON-shaped values back into dicts on read.
Defenses against LLM failure modes
- Hallucinated candidate IDs: every returned
candidate_idis validated against the input set; rows with unknown IDs are dropped. - Out-of-enum severity: normalized to
Nonebefore 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_itemscorpus 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 = 100if a domain blows past that. - Test rewrites. All contradiction-side tests previously asserted on
the legacy single-pair
{contradicts, …}schema andtitle_wordsparameter. 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:
pytest tests/test_memory_api.py tests/test_corporate_memory_v1.py -v