ADR-0116: Copilot Chat-Surface Governance — Escalate-Only for Gate-Feeding Discrepancies + Immutable copilot_action Audit (#371)
Date: 2026-07-17 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Fable 5) — epic #350 wave 3; extends ADR-0059 (open-UBO-discrepancy gate), ADR-0064 (audit immutability), ADR-0109 (hash chain); sibling of ADR-0102 (governed chat teaching)
Decision context:
- Latency: one additional
AuditService.log_eventper permitted chat mutation (resolve/note/remember/feedback — officer-paced, a handful per case) — each an advisory-lock-serialised single-row append (ADR-0109) after the tool's own commit, off the render path. The gate guard itself is a pure in-memory predicate call (zero I/O). - Dependency surface: zero new packages. Reuses
is_ubo_identity_discrepancy(the gate's OWN predicate),AuditService.log_event(the ONE hash-chained writer), and a single new ~40-line React component. Owned lines: one guard block, one audit helper, seven one-line disclaimer mounts. - Debuggability: every chat-driven mutation becomes an immutable, per-tenant hash-chained
copilot_actionrow (tool name, args summary, officer_id,via='copilot') joinable to the existingsignal_eventsprovenance row. A failed audit write is LOUD (logger.warningwith traceback), never silent. - Reversibility: fully reversible in minutes — delete the guard block, the helper calls, and the disclaimer mounts; no migration, no schema change, no workflow touch. Existing rows remain valid audit history.
- Blast radius: additive. Non-gate-feeding discrepancies stay chat-resolvable;
escalatedstays chat-invocable everywhere (adds scrutiny). The only behavior removed is the one that was a defect: an LLM tool call clearing a fail-closed approval gate. - Alternative considered: duplicating a "feeds the gate" predicate inside the agent — rejected; the guard imports the gate module's own function, so guard and gate can never drift apart (single source of truth).
Context
Issue #371 (UI-assessment epic #350) verified three related defects on the CopilotKit chat surfaces:
- No assistance-only marker. Seven chat surfaces (case-page popup, dashboard, memory, regulatory, standards, onboarding, customer portal) rendered no indication that the assistant is assistance — not a compliance determination. An officer (or a customer) could reasonably read a chat answer as the system's decision.
- A chat tool could clear a fail-closed gate.
resolve_discrepancy(a chat-invocabledashboard_agenttool) inserts aDiscrepancyResolutionrow that the ADR-0059 open-UBO-discrepancy approval gate READS (case_decisions_service.evaluate_open_discrepancy_blockmaps any non-escalated/reportedresolution to statusresolved, which removes the discrepancy fromevaluate_approval_block's blocking set). So one LLM tool call — no UI confirmation, no Permission check, attributable only to the'chatbot'fallback identity when deps carried no officer — could clear a hard fail-closed approval gate. THE invariant this module lives by is: chat may ADD scrutiny, never clear a gate; a false clear is worse than no answer. - Chat mutations bypassed the immutable trail. The mutating tools
(
resolve_discrepancy,add_case_note,remember_this,submit_finding_feedback) wrote provenance tosignal_events(resolved_via='chatbot', non-suppressible) but never to the ADR-0064 immutable / ADR-0109 hash-chainedaudit_events— the table the compliance story (EU AI Act Art. 12, AMLR 5-year retention, regulator case-pack) hangs off.
Decision
-
Escalate-only from chat for gate-feeding discrepancies (fail-closed).
resolve_discrepancyrefuses — returning an explanatory message steering the officer to the Conflicts panel's audited resolution flow — any resolution of a discrepancy for whichis_ubo_identity_discrepancy(imported fromapp/services/discrepancy_approval_gate.py, the SAME predicateevaluate_approval_blockkeys its blocking set on — never a duplicated, drift-prone copy) is true, EXCEPTresolution_type='escalated', which adds scrutiny and remains permitted. Non-gate-feeding discrepancies stay chat-resolvable. -
Real officer identity required.
resolve_discrepancyrefuses outright when the officer identity would fall back to the anonymous'chatbot'actor — a row that can influence an approval gate must be attributable to a named human. -
Immutable
copilot_actionaudit for every permitted chat mutation.resolve_discrepancy(when permitted),add_case_note,remember_this, andsubmit_finding_feedbackadditionally write anaudit_eventsrow (event_type='copilot_action'; details: tool name, args summary, officer_id,via='copilot') throughAuditService.log_event— the ONE ADR-0109 per-tenant hash-chained writer — alongside the existingsignal_eventswrite. Best-effort by explicit contract: a failure never blocks the tool but logs a LOUDlogger.warningwith traceback; never silent. -
Persistent assistance-only disclaimer on every chat surface. A shared
AssistantDisclaimercomponent renders a small fixed footer adjacent to each CopilotPopup mount (the popup chrome exposes no supported footer slot): officer surfaces — "AI assistance — not a compliance determination. Verify against case evidence. Actions are logged."; customer portal — "AI assistant — for guidance only; it does not make decisions about your case." The exact wording is snapshot-asserted in Jest so it cannot silently disappear or drift.
Deferred follow-up: true pre-execution human-in-the-loop confirmation for chat
mutations via CopilotKit's renderAndWaitForResponse (the officer approves the tool
call in the chat UI before it executes). This ADR's guard is server-side and
fail-closed regardless of UI; the HITL layer is additive UX and tracked separately.
Consequences
Positive
- The ADR-0059 gate can no longer be cleared by a conversational side-effect: the only chat verb for a gate-feeding discrepancy is escalation (adds scrutiny). The audited Conflicts-panel flow is the single clearing path.
- Chat-driven mutations are now tamper-evident:
copilot_actionrows ride the per-tenant ADR-0109 hash chain and the ADR-0064 immutability controls, retrievable for the regulator pack, joinable tosignal_eventsprovenance. - Every chat surface now states its epistemic status; the customer portal wording avoids implying the assistant decides anything about the case (AMLD tipping-off posture unchanged — the portal agent remains read-only).
Negative
- Officers can no longer resolve UBO/identity/critical discrepancies conversationally even when legitimate — one extra click to the Conflicts panel. Accepted: that flow carries explicit confirmation and audit; a false clear is worse than the friction.
audit_eventsgrows by a handful of rows per case (officer-paced chat mutations). Negligible next to the ~27 agent-lifecycle rows per run (ADR-0115).
Neutral
- No migration:
copilot_actionis a newevent_typevalue in an existing table; no schema, workflow, or API signature changes. - The
signal_eventswrites are unchanged —copilot_actionis an additional immutable mirror, not a replacement. remember_thiskeeps its ADR-0102 governed-store guard (never-suppress); the new audit row records that the save was driven from the chat surface.
Alternatives Considered
Alternative 1: Duplicate a "feeds the gate" predicate in the agent
Hard-code the UBO field list / critical-severity check inside resolve_discrepancy.
Rejected: two predicates drift — the exact unreconciled-paths defect class the
case-pack audit remediation spent a milestone killing. The guard imports the gate's
own is_ubo_identity_discrepancy.
Alternative 2: Remove resolve_discrepancy from the chat toolset entirely
Rejected: escalation from chat is valuable (it ADDS scrutiny — precisely what the invariant permits), and non-gate-feeding discrepancies (e.g. phone-number variance) are low-risk conveniences. Blanket removal trades safety already achieved by the guard for workflow regression.
Alternative 3: Pre-execution HITL confirmation as the primary control
Gate every mutation on a CopilotKit renderAndWaitForResponse approval widget.
Rejected as the primary control: it is client-side UX — a bypassed/legacy client
would still reach the tool. The server-side fail-closed guard is the control; HITL
confirmation is the deferred additive layer.
Alternative 4: Write copilot_action into signal_events only
Rejected: signal_events is not the immutable, hash-chained table the compliance
story leans on (claim-vs-check — provenance that never reaches the audit chain
reports the control's shape, not its state).