ADR-0019: Support portal + consent-based diagnostic bundles
Status: Accepted · 2026-07-15Context
With the customer journey runnable through evaluate → download → install → activate (ADR-0016–0018), the last major lifecycle surface (web design §8) is support: a customer needs to raise an issue and, when helpful, share operational diagnostics — without exposing any of their data. The design’s hard constraint: diagnostic bundles are generated inside the customer environment, previewable, redacted, size-limited, explicitly approved, retention-limited, access-logged, and free of content/secrets by default.Decisions
1. The bundle is built in the deployment, held to the telemetry discipline
support/diagnostics.ts builds a DiagnosticBundleSchema.strict() bundle from
the deployment: product/version, non-secret config (env, auth mode, connector
mode, feature flags), migration count + latest name, entitlement state/edition,
scanner counts, health bands (queue/dead-letter/scale), and error
categories (RETRY_CLASSES) with banded counts — never raw counts, never
the free-text safe_diagnostic. The deployment id is a non-reversible tenant
hash. This is the same .strict() allowlist + SENSITIVE_FIELD_DENYLIST guard
pattern as fleet telemetry (ADR-0014 §5), extended for richer operational
diagnostics.
2. Preview-before-send is a first-class step
GET /api/support/diagnostics/preview (capability operations.read) returns the
exact bundle a support upload would contain — generated locally, nothing
sent. The customer sees precisely what they would share before any deliberate
action. There is no deployment-side endpoint that transmits the bundle anywhere;
uploading is a separate, explicit action the customer takes on the Account portal.
3. The vendor re-validates on ingest — the boundary holds on both ends
Uploading a bundle to a support case (POST …/support-cases/:id/bundle)
re-parses it through the same DiagnosticBundleSchema before storing. So even
if a deployment were compromised or misconfigured to attach extra fields, the
vendor plane rejects anything off the allowlist (400) — customer content cannot
be stored in the vendor DB. The strict schema is the boundary on the build side
and the ingest side.
4. Support is membership-guarded but not entitlement-gated
Support-case routes require org membership (non-members get 404, no existence leak) with CSRF on mutations, but are not blocked by account suspension or expiry — a customer with a lapsed license must still be able to get support. Reading a stored bundle enforces account ownership + retention expiry and logs the access (bundle_access_log); bundles are retention-limited
(FMD_ACCOUNT_SUPPORT_RETENTION_DAYS, default 30) and returned as metadata in
case listings.
5. Reuse a capable platform later; keep the contract clean now
Per the design, a capable ticketing/knowledge platform is preferable to building commodity support software. This build implements the contracts — case lifecycle, the consent-based bundle, retention, access logging — so a real platform can be integrated behind them without changing the customer-facing model.Consequences
- The full lifecycle is now runnable: visit → register → evaluate → download → activate → support (raise a case, preview + optionally share a redacted diagnostic) → convert.
- Support staff never gain access to the customer’s installed platform or data; they receive only what the customer explicitly, visibly approved.
- Out of scope (future): a real ticketing/KB integration, in-portal case threading/replies, bundle size caps beyond the schema’s inherent bound, and automated retention sweeps.
Verification
support/diagnostics.test.ts (7) — allowlist/denylist guard, pseudonymous id,
error categories as banded counts (safe_diagnostic text proven absent),
per-tenant scoping, and the preview route (bundle shape + capability gate + tenant
id absent). account/support.test.ts (5) — case create (CSRF + membership),
cross-account 404 on list/create/upload, valid-bundle upload + access-logged read,
and rejection of a content-carrying bundle on ingest (400). All 277 server
tests pass; typecheck + lint clean; adversarially reviewed.