Skip to main content

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_event per 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_action row (tool name, args summary, officer_id, via='copilot') joinable to the existing signal_events provenance row. A failed audit write is LOUD (logger.warning with 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; escalated stays 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:

  1. 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.
  2. A chat tool could clear a fail-closed gate. resolve_discrepancy (a chat-invocable dashboard_agent tool) inserts a DiscrepancyResolution row that the ADR-0059 open-UBO-discrepancy approval gate READS (case_decisions_service.evaluate_open_discrepancy_block maps any non-escalated/reported resolution to status resolved, which removes the discrepancy from evaluate_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.
  3. Chat mutations bypassed the immutable trail. The mutating tools (resolve_discrepancy, add_case_note, remember_this, submit_finding_feedback) wrote provenance to signal_events (resolved_via='chatbot', non-suppressible) but never to the ADR-0064 immutable / ADR-0109 hash-chained audit_events — the table the compliance story (EU AI Act Art. 12, AMLR 5-year retention, regulator case-pack) hangs off.

Decision

  1. Escalate-only from chat for gate-feeding discrepancies (fail-closed). resolve_discrepancy refuses — returning an explanatory message steering the officer to the Conflicts panel's audited resolution flow — any resolution of a discrepancy for which is_ubo_identity_discrepancy (imported from app/services/discrepancy_approval_gate.py, the SAME predicate evaluate_approval_block keys its blocking set on — never a duplicated, drift-prone copy) is true, EXCEPT resolution_type='escalated', which adds scrutiny and remains permitted. Non-gate-feeding discrepancies stay chat-resolvable.

  2. Real officer identity required. resolve_discrepancy refuses 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.

  3. Immutable copilot_action audit for every permitted chat mutation. resolve_discrepancy (when permitted), add_case_note, remember_this, and submit_finding_feedback additionally write an audit_events row (event_type='copilot_action'; details: tool name, args summary, officer_id, via='copilot') through AuditService.log_event — the ONE ADR-0109 per-tenant hash-chained writer — alongside the existing signal_events write. Best-effort by explicit contract: a failure never blocks the tool but logs a LOUD logger.warning with traceback; never silent.

  4. Persistent assistance-only disclaimer on every chat surface. A shared AssistantDisclaimer component 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_action rows ride the per-tenant ADR-0109 hash chain and the ADR-0064 immutability controls, retrievable for the regulator pack, joinable to signal_events provenance.
  • 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_events grows 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_action is a new event_type value in an existing table; no schema, workflow, or API signature changes.
  • The signal_events writes are unchanged — copilot_action is an additional immutable mirror, not a replacement.
  • remember_this keeps 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).