> ## Documentation Index
> Fetch the complete documentation index at: https://docs.findmydata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0023 plain support mirror

# ADR-0023: Plain support mirror (best-effort, content-bounded, off the response path)

**Status:** Accepted · 2026-07-15

## Context

ADR-0019 built the support surface (portal-native cases + consent-based diagnostic
bundles) behind clean contracts, and named "a capable ticketing/KB platform can be
integrated behind them" as the next step. This ADR wires the first such platform,
**Plain** (plain.com), so the support team works cases in a real tool while the
portal stays the customer-facing source of truth. The hard constraint is the
product's content boundary: nothing customer-governed may cross into the vendor's
third-party support SaaS.

## Decisions

### 1. The mirror is best-effort and runs OFF the response path

When a case is opened, the portal case row is committed first, the 201 is returned,
and the Plain mirror (`account/support-plain.ts`) runs as a background task
(`AccountDeps.onMirror` exposes the in-flight promise for deterministic tests).
`forwardCaseToPlain` never throws — every failure is a `{ ok: false, reason }` — and
the whole block is guarded, so a slow, hung, unreachable, or erroring Plain (or a
transient DB error while recording the outcome) can never delay or fail case
creation. Each outcome records a control event (`support_case_mirrored_plain` /
`support_case_mirror_failed`, `safeDetail` only). This replaced an earlier inline
`await` that put up to \~10–20s of Plain latency on the case-creation critical path
(caught in adversarial review).

### 2. Only case metadata crosses — never customer content or the org name

The mirror sends the opener email + name (to upsert the Plain customer), and the
case subject + severity + portal case id (in the thread). The thread body is a
fixed content-free summary. The customer organization's **display name is not
sent** — the portal case id already correlates the thread inside the vendor plane,
and the opaque account id is the only handle used. (Review flagged an earlier
version that interpolated the org display name into the summary; it was removed.)
The case **subject** is customer-authored free text and is on the allowlist because
a support tool needs it to triage — it is the one field where a user could type
sensitive text, so support staff treat it accordingly; it is never scrubbed here.

### 3. Least-privilege machine-user key, resolved by reference, correct endpoint

Plain access is a machine-user API key scoped to exactly `customer:create`,
`customer:edit`, `thread:create`. It is resolved from the environment by reference
(`FMD_PLAIN_API_KEY_REF`) and never committed; unset ⇒ the integration is off and
cases stay local only. The endpoint is Plain's global GraphQL host
`https://core-api.uk.plain.com/graphql/v1` (`FMD_PLAIN_API_URL` overrides). A live
end-to-end test against the real workspace **caught a wrong hardcoded host**
(`core-api.plain.com`, which does not resolve) before release — a bug the
fire-and-forget design would otherwise have swallowed silently.

### 4. GraphQL responses are parsed defensively

A GraphQL top-level error nulls the mutation field and populates `errors[]`; the
parser optional-chains the field (`data?.upsertCustomer?.customer?.id`) so that
shape falls through to the real `errors[0].message` instead of throwing a
`TypeError` mislabeled as "unreachable" — important because a scope/permission
failure on the least-privilege key produces exactly that shape. Non-2xx transport
responses are classified by status (`plain_http_429`, `plain_http_5xx`) rather than
swallowed as a generic failure.

## Consequences

* The support team gets threads in Plain automatically, with no change to the
  customer-facing case model and no new failure mode on case creation.
* The content boundary holds on the vendor's own tooling: only enumerated metadata
  crosses, verified by a boundary assertion in the test suite.
* **Follow-up (not in scope here):** the case-creation endpoint has no idempotency
  key, so a user double-submitting creates duplicate portal cases (and therefore
  duplicate Plain threads, 1:1). This is a pre-existing property of the portal API,
  not introduced by the mirror; the review's automated-retry driver (response-path
  latency) is fixed, leaving only manual double-submit. Endpoint-level idempotency
  (an `Idempotency-Key`) is a cross-cutting portal improvement tracked for later.

## Verification

`account/support-plain.test.ts` (9 tests): upsert→create happy path with a content
-boundary assertion (no org name, no opener name in the thread); upsert/thread
error surfacing; the null-field-plus-`errors[]` regression (returns the real error,
not `plain_unreachable`); non-2xx status classification; network failure caught;
route mirror records the failure event with Plain down; **case creation is not
blocked by a hung Plain**; and no mirror attempted when unconfigured. Plus a live
end-to-end thread creation against the real Plain workspace. Full server suite (320
tests) passes; typecheck + lint clean. Reviewed adversarially (content-boundary,
correctness, robustness lenses with per-finding verification); all four confirmed
findings fixed.
