Skip to main content

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.