ADR 0141 — Support Portal + CRM Surface Topology
Status: Accepted
Date: 2026-07-17 UTC
Deciders: Kristerpher Henderson (operator), software-architect
Scope: support.raxx.app, console.raxx.app (CRM section), Raptor API, freescout_ticket_cache
Context
The operator decided on 2026-07-14 (#4250) that console.raxx.app would serve as the customer CRM. This was in tension with #651, which envisions a standalone support.raxx.app SPA as the customer-facing support portal. Both decisions were in flight simultaneously, with implementation cards blocked waiting for this reconciliation.
Two prior sub-cards complicated matters:
- #652 (data model + API contract spec) was deferred and never written — its content is now folded into docs/architecture/support-raxx-app.md and this ADR.
- #3286 (SC-A4 audit webhook) would populate freescout_ticket_cache, but that table's role was ambiguous — audit side-table or CRM primary store?
This ADR resolves the surface ownership question and the freescout_ticket_cache role, unblocking all downstream cards.
Decision
Both surfaces ship, serving different audiences.
support.raxx.app is the customer-facing self-service portal. Customers sign in with their Raxx passkey, see only their own tickets, and never encounter FreeScout by name. This is the surface #651 describes, and it remains the correct customer experience.
console.raxx.app gains a CRM section (/customers/:id/support) where operators view any customer's ticket history, see Raxx account context alongside support context, and link out to FreeScout for full ticket management. "Customer CRM" in #4250 refers to a tool the Raxx team uses to manage customers — not a portal customers log into.
freescout_ticket_cache is promoted from audit side-table to first-class CRM data store. It is populated by the FreeScout webhook (push, via #3286) and by a pull-on-demand hydration path at CRM customer-profile load time (for historical tickets that predate webhook registration). The customer-facing portal reads live from FreeScout on demand and does not use this cache.
The full data model, API contract, and migration spec live in docs/architecture/support-portal-crm-reconciliation.md.
Language choice rationale
This ADR does not introduce a new service. All new code runs within Raptor (Python, Tier 2) and the existing console.raxx.app (Antlers/Next.js). No language-tier classification is required.
Consequences
Positive
- Resolves the surface ownership ambiguity that had blocked #655, #3286, #2071, and the freescout_ticket_cache schema decision.
- The two surfaces have clean separation: customer data is scoped by passkey JWT on the customer portal; operator data is scoped by console auth on the CRM view. Neither bleeds into the other.
- The
freescout_ticket_cacheas CRM store gives the operator a fast, Postgres-backed customer profile load without a synchronous FreeScout API call on every page view. - The customer portal (
support.raxx.app) is already 60% built (#653 CLOSED, #654 CLOSED). The remaining work is the SPA screens (#655) and FreeScout mailbox config (#656).
Negative / risks
freescout_ticket_cacheis eventually consistent. A ticket updated in FreeScout between webhook events will appear stale in the CRM. This is acceptable for the operator CRM (they can click through to FreeScout for live state); it is not a concern for the customer portal (which reads live). The webhook latency is expected to be under 30 seconds under normal FreeScout load.- The pull-on-demand hydration path adds a synchronous FreeScout API call on cache miss at CRM load time. Rate-limited to 10 hydrations per customer per hour. If FreeScout is slow, the CRM customer profile load is slow. Mitigation: the CRM panel renders a skeleton and the hydration fires as a background request; the operator sees cached data immediately and the panel refreshes.
- The GDPR DSR path now requires redacting
customer_emailinfreescout_ticket_cachefor deleted users (in addition to the existingON DELETE SET NULLonraxx_user_id). This is a new step in the DSR automated job. See OQ-2 in the design doc.
Neutral
-
652 is superseded and closed. Its content is now in
support-raxx-app.mdand the reconciliation doc. No implementation work is lost since #652 was docs-only and never implemented. console.raxx.app's identity changes: it is now explicitly a customer CRM, not an ops/infra hub. The ops/infra hub isconsole.moosequest.net. This is a naming and mental-model clarification, not a code change.
Alternatives considered
Alternative A: support.raxx.app only; no CRM section in console.raxx.app
Rejected because: the 2026-07-14 operator decision (#4250) explicitly named console.raxx.app as the customer CRM. Ignoring that direction would require re-re-deciding it and would leave operators without a unified customer view.
Alternative B: console.raxx.app absorbs support.raxx.app; customers log into console
Rejected because: console.raxx.app is operator-authed (console RBAC, SAML-ready). Mixing customer auth with operator auth on the same surface violates the privacy boundary: a misconfigured RBAC row could expose one customer's tickets to another. The two audiences need separate auth contexts and separate surfaces.
Alternative C: freescout_ticket_cache stays audit-only; CRM queries FreeScout live
Rejected because: live FreeScout API queries at every CRM customer-profile load would create rate-limit pressure and latency spikes when FreeScout is slow or unreachable. The cache gives the CRM a fast, stable read path. Customer portal remains unaffected (still reads live).
Security / GDPR checklist
- PII collected by freescout_ticket_cache: customer email, ticket subject. No ticket body content.
- Retention period:
freescout_ticket_cacheinherits the FreeScout retention decision (OQ-4 in design doc; recommend 2 years). No separate Raptor retention policy for this cache — rows are authoritative only if the FreeScout conversation still exists. - Deletion on DSR:
ON DELETE SET NULLonraxx_user_idfires on user deletion. The GDPR DSR job must also setcustomer_email = '<redacted>'(OQ-2). FreeScout-side ticket deletion is manual (OQ-3, pending attorney guidance). - Audit trail: All customer portal actions logged in
support_audit_log. Operator CRM access logged in the console audit log. Webhook upserts attributed tosource: freescout_webhookwith timestamp. - Stored credentials: FreeScout API token at Infisical
/raxx/prod/freescout-api-token, loaded asFREESCOUT_API_TOKENHeroku config var. Webhook secret at Infisical/MooseQuest/freescout/FREESCOUT_AUDIT_WEBHOOK_SECRET, loaded as Heroku config var. Neither value ever leaves the server. - Breach notification:
freescout_ticket_cacheexfiltration: customer emails and ticket subjects exposed. No financial data, no ticket bodies. GDPR Art. 33/34 notification within 72h via Raptor's existing breach-notification pipeline. - Secrets location + rotation: Both secrets rotatable without redeploy (Heroku config var update + dyno restart).
- Kill-switch:
FLAG_SUPPORT_PORTAL_API=0(customer SPA) andFLAG_FREESCOUT_WEBHOOK_RECEIVE=0(cache sync) are independent. Both can be toggled without affecting FreeScout email intake.
Revisit when
- Queue Phase 2 ships and
customers/by-emailownership moves from Raptor to Queue. At that point, thesupport_summaryenrichment in the endpoint contract (§4c of the design doc) may move as well. - CRM phase 2 (operator reply from console instead of FreeScout UI) — this ADR scopes CRM as read-only plus a link to FreeScout. A write-capable CRM integration would need its own ADR.
- If FreeScout is replaced or decommissioned — all FreeScout API calls are mediated through Raptor; the customer SPA and CRM panel have no direct FreeScout dependency. A FreeScout replacement changes the Raptor integration layer, not the surface contracts.