Skip to main content

ADR-0019: Support portal + consent-based diagnostic bundles

Status: Accepted · 2026-07-15

Context

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.