ADR-0196: Portal chat persistence — token plus an officer-issued passphrase
Date: 2026-08-25 Status: Accepted Deciders: Adrian (project owner — scope, identity model, second factor and the three §8 questions, recorded 2026-08-23 and 2026-08-24), Claude Opus 5
Context
Reopening the customer portal in a new browser window started the assistant empty. The customer could not see what they had already asked or been told, and neither could anyone else — the conversation was gone when the tab closed.
That is not only usability. The portal assistant answers questions about which documents are required and why, and under EU AI Act Art. 12 the determination path is expected to be reconstructable. A conversation that influenced what a customer submitted, and no longer exists, cannot be reconstructed at all.
The integration point was designated rather than invented:
frontend/src/app/api/copilotkit/threads/route.ts already answered CopilotKit's
thread-list call with a hard-coded { threads: [] }, and its own comment read
"If/when we add real thread persistence it becomes a proper feature (own
ADR)."
Decision
Persist the customer portal assistant's conversation per case, gated on the portal token plus an officer-issued passphrase.
- Identity. The portal token continues to identify the CASE. A passphrase,
issued by the officer from the dashboard and conveyed out of band, unlocks the
TRANSCRIPT.
POST /api/cases/{workflow_id}/portal-chat-passphrase(CASE_WRITE) returns it exactly once over a stored SHA-256 digest; re-issuing revokes the previous session. - Verification fails to TODAY'S BEHAVIOUR, not to an error. A wrong
passphrase, an absent one, an expired token or an active rate-limit lock all
return HTTP 200
{"verified": false}.app/api/portal_chat.pycontains noHTTPExceptionat all, asserted by a test. - Rate limiting is the control, not the length. The passphrase is
token_urlsafe(9)because a human reads it over the phone. Five failures lock for fifteen minutes, and a sixth attempt with the CORRECT passphrase is still refused while locked. - Storage.
portal_chat_messagesandportal_chat_sessions(Alembic 108), both carryingtenant_isolationANDadmin_bypasspolicies plus FORCE RLS.contentisEncryptedText(AES-256-GCM, ADR-0106). - The case comes from the token-resolved row, never from the request body.
portal_context_rawis parsed from the body, so acase_idtaken from there is a case the customer chose. - Officers may read the transcript, and every read is audited.
GET /api/cases/{workflow_id}/portal-transcript(CASE_READ) writes an immutableportal_transcript_readevent carrying reader, case and message COUNT — never content. - Two disclosures. The customer is told an officer can read the conversation, and told when the cap has truncated it.
The audit/persistence asymmetry is deliberate
A failed transcript write is swallowed: the assistant still answers. A failed audit write fails the request. These sit one file apart and look inconsistent; they are not. A transcript line is a convenience, and failing to help a customer is worse than losing one. An audit row records who accessed personal data — it is the control that makes "officers may read transcripts" defensible, so a read nothing recorded is precisely the read that should not have happened. Both files say so in comments, because the obvious tidy-up is to harmonise them.
Consequences
Positive
- A returning customer sees their conversation, and the record exists for Art. 12.
- The transcript is encrypted at rest and RLS-scoped like every other tenant table.
- Officer access is accountable per read rather than in aggregate.
- The expired-token rule, previously enforced by the upload surface and NOT by the assistant, is now one shared predicate used by both.
Negative
- Writing a turn requires no session credential; only RESTORE does. The first shape gated persistence on a verified session, and that made the feature unable to bootstrap: the passphrase surface appears only when history exists, so a first visit had no surface, therefore no token, therefore no persisted turn — and history could never come to exist. Found by Codex on PR #1211; owner decision 2026-08-26. This is the ADR's own principle applied consistently — denying HISTORY is the control, denying HELP is an outage — and the portal token already authorises uploading DOCUMENTS into the case, so writing the customer's own words is the same trust class. Stated cost: someone holding a forwarded portal link can add messages an officer later reads. That is the exposure document upload already carries, and the portal token remains the boundary for both. Reading the transcript back is unchanged — token PLUS passphrase.
- The session credential is a header, never a URL parameter and never model
context. Two defects, both found on PR #1211. It was in the query string
beside the portal token already in the path, so one proxy or access-log line
held both factors needed to replay the transcript for an hour. And it sat in
the readable-context blob, which
useCopilotReadableserialises into a developer message — so every verified turn shipped it to the external LLM provider, for a value the model has no use for. It now travels inX-Portal-Session, read by the restore path alone. A header is not secret either; it is simply not what request logging captures by default. - The mint binds to the row that matched, not to whatever is active now.
_verifyreturned a bare bool and the caller then asked which session was active, which are different questions the moment an officer re-issues in between: a request presenting the OLD, revoked passphrase was handed a token for its REPLACEMENT._verifynow returns the matched row's id and the dead lookup is removed rather than left available to the next caller. Revocation is still enforced at use. - The feature was built and not reachable from the browser (#1208). Nothing
let a customer ENTER the passphrase:
passphraseappeared nowhere infrontend/src/outside the threads route, and CopilotKit's thread fetch sent onlyagentId. Every task was individually complete; the SEQUENCE had no entry point — the ADR-0194 defect reproduced one layer up, in the plan rather than the code. Closed by #1211, which addedPortalChatRestore.tsxand its entry point on the portal page. The lesson stands: a plan can carry the same claim-vs-check defect as the code it plans. - The passphrase travelled on every chat turn, because verification returned
{verified}and no session token — multiplying transmissions of a credential and the chances of one landing in a log or proxy trace. Closed by #1211: the passphrase is exchanged ONCE for a short-lived (1 h) signed session token, and every downstream surface accepts the token instead. - Portal chat now requires
pii_encryption_keyto be configured, and says so at boot. The session token is signed with a key HMAC-derived (domain-separated) from it, because no dedicated application signing secret exists and the PII pepper is scoped to search hashes. The signer REFUSES to mint or verify without it — correct, since an absent key means signing with a predictable one, and a forgeable session token is worse than a disabled feature. Two costs are accepted rather than hidden: rotating the PII key invalidates every live chat session, and a deployment running withpii_encryption_enabled=false(dev, CI) cannot use the feature at all. Found by CI, not by review — the tests passed locally, wherebackend/.envcarries a key, and failed in CI, which deliberately carries none. The refusal previously surfaced only as a 500 at a customer's FIRST use, becauseensure_pii_key_availableno-ops when encryption is off — exactly the deployment with no key.disclose_portal_chat_signing_key_statenow names the consequence and the fix at boot, DISCLOSED and never blocking (ADR-0166): refusing to boot the API over one feature's key would fail every keyless dev run, which is how a control gets switched off (ADR-0121). - The second factor stops incidental replay, not a determined holder of the token. A forwarded link, a shared screen, history on a shared machine. It is NOT claimed to defend a transcript against someone who has both factors.
contentisbyteaand cannot be SQL-searched. Accepted: a searchable plaintext transcript is a worse artifact than an unsearchable encrypted one.- The disclosures rendered English only, while the portal localises 214 keys
into
csandro(#1207). A disclosure the data subject cannot read does not inform them. Partly closed by #1211: the strings are now routed through the locale system rather than hard-coded, so the remaining gap is translation and not architecture. Thecsandrovalues ship marked[NEEDS REVIEW]and a speaker must review them before they can be called translations — deliberately not machine-translated and silently shipped, since a mistranslated Art. 5(1)(a) notice states something specific and wrong with the product's own authority.
Neutral
- The passphrase field is unmasked (
type="text"). Recorded rather than left looking like an oversight: the passphrase is nine characters because a human reads it over the phone, it is typed once, and a typo is not free — five wrong attempts lock the transcript for fifteen minutes. Masking would guard against someone reading the screen, for a credential gating that customer's own conversation; rate limiting, not masking, is the control this design relies on. No existing behaviour changes without a verified session; the flag-off path is byte-identical, including no writes.Withdrawn. This described the original design and was left standing when writes stopped requiring a session (see the first Negative consequence above) — leaving two mutually exclusive authentication contracts in one accepted record (Codex, PR #1211). What holds now: a turn is persisted whenever the portal token resolves a case, and a verified session gates READING the transcript back. Struck rather than deleted, so the record shows which contract was superseded and when.- Retention follows the case's own clock (ADR-0139/0140) and the crypto-shred path (ADR-0142). No new policy.
Alternatives Considered
Alternative 1: Token alone, no second factor
- The portal token already identifies the case; use it for the transcript too.
- Why rejected: the token is a bearer credential in a URL — emailed, in browser history, designed to be shareable with whoever handles the paperwork. Fine for uploading into a case the sender already knows about; not sufficient for replaying what the customer said about ownership and sources of funds.
Alternative 2: Notify the customer of each officer read
- Maximum transparency, arguably the strongest Art. 5(1)(a) posture.
- Why rejected (owner decision, spec §8): per-read notification trains customers to ignore it, reads as surveillance of routine casework, and pressures officers away from context they should be reading. Each read stays accountable through the immutable audit trail — to a regulator, which is who that accountability is for.
Alternative 3: Persist the dashboard assistants too
- One mechanism for every chat surface.
- Why rejected: scoped out by the owner. An officer's own working notes raise a different question and deserve their own decision.
Alternative 4: Accept a client-supplied transcript
- Simplest: let the browser send what it has.
- Why rejected: a client-supplied transcript is a transcript the customer can author. Only the message text is taken from the request; role, case, tenant and timestamp are server-determined.
Decision context:
- Latency: one extra INSERT per chat turn, guard-and-swallowed; one indexed
SELECT on history restore. No added LLM call.
- Dependency surface: no new packages. Reuses EncryptedText, hash_api_key,
AuditService and the existing portal-token resolution.
- Debuggability: a refused verification is indistinguishable from an absent one
BY DESIGN, which is a deliberate cost to debuggability paid for not revealing
whether a passphrase exists. Persistence failures are logged loudly with the
case id.
- Reversibility: the migration has a tested down path (up/down/up on a throwaway
database). With no verified session nothing is written, so reverting the API
leaves rows that simply stop being read.
- Blast radius: two new tables, three new endpoints, one modified agent path,
one frontend route that changes behaviour ONLY for portal_assistant with a
verified session — asserted for the other three agents.
- Alternative considered: token alone — rejected because a URL-borne bearer
credential should not replay a conversation about beneficial ownership.