ADR 0061 — Ticket-State-Aware Notification: Two-Path Model for Dim-3 Operator Reads
Status: Accepted
Date: 2026-05-09 UTC
Deciders: operator (Kristerpher), software-architect
Refs: customer-audit-unified/design.md §7, ADR-0060, project_workflow_uuid_tracing_decisions.md, docs/security/customer-audit-unified-threat-model.md §3.4, #1456
Context
When an operator reads a customer's Dimension 3 (operator_interaction) audit data, invariant I-8 requires proactive customer notification. The v1 design used a single nightly batch notification for all Dim-3 reads — regardless of whether the access occurred within an active support ticket or after ticket closure.
The security agent identified this as a gap (#1456, threat T-INS-1): a nightly batch SLA means a customer is not informed of an unauthorized admin read until up to 23:59 hours after the event. For an unauthorized post-resolution read, this is not proactive; it is retrospective.
The operator also identified that a single notification framing cannot serve both cases correctly. An in-ticket read ("our support team is working your ticket") requires a welcoming, trust-building tone. A post-resolution read ("someone accessed your account after your ticket was closed") requires a security-incident framing.
The discriminator for the correct path must be captured at write time, not derived later. This means the customer_audit_events table must store ticket_state_at_read — the FreeScout ticket status at the moment the operator accessed the data.
Decision
Two-path notification model, discriminated by ticket_state_at_read.
Path A — In-ticket read (welcoming)
Condition: ticket_state_at_read IN ('open', 'in_progress', 'pending')
- Postmark transactional email dispatched within 5 minutes via event-driven job.
- Tone: welcoming transparency signal. The customer opened the ticket; this read is part of the service they requested. Frame as "we're working for you, here's the receipt."
- Audit action:
customer.data.read.in_ticket. - No Sentry alert. Expected behavior.
Path B — Post-resolution / no-ticket read (security incident)
Condition: ticket_state_at_read IN ('resolved', 'closed', 'none'), or ticket_id IS NULL
- Sentry CRITICAL alert fired immediately with operator identity hash, customer ID, and timestamp.
- Customer notified by email within 5 minutes with security-incident framing.
- Audit action:
customer.data.read.post_resolution,severity=incident. - Architecture supports future manager-routing and HR-packet hooks; v1 emits the audit row + Sentry alert only.
Ticket-state acquisition
ticket_state_at_read is populated synchronously at write time from the freescout_ticket_cache table. The cache is maintained by FreeScout webhook events (POST /api/internal/freescout-webhook). If the cache is missing or expired, ticket_state_at_read = 'none' (fail-closed → Path B).
Superadmin path
Superadmin (raptor-audit-admin) reads always trigger Path B. Superadmins have no ticket_id linkage requirement; their access is unconditionally a security-significant event that the customer must know about.
Compliance auditor path
raptor-audit-compliance (SOC-2 auditor role) reads do NOT trigger either notification path. Auditor reads are aggregate and operational; customer notification would be inappropriate and would constitute noise that degrades the notification's meaning.
No opt-out
Customer notification has no opt-out (invariant I-12). The notification is a GDPR Art. 13/14 transparency obligation, not a preference. Customers may choose not to read the email; the right to receive it is non-waivable.
Consequences
Positive
- Notification tone matches context. A welcoming email for in-ticket reads builds trust. A security-incident email for post-resolution reads signals that the system is watching and the customer's rights are protected.
- Path B catches unauthorized access within 5 minutes, providing a meaningful incident detection window for the customer (and for the operator, who receives the CRITICAL Sentry alert simultaneously).
ticket_state_at_readas a persistent column creates a queryable record of every access decision. Compliance auditors can verify that all Path B events were investigated.- The fail-closed design (cache miss → Path B) is conservative in the right direction. A brief FreeScout downtime causes over-notification, not under-notification.
Negative
- FreeScout downtime causes Path B notifications for legitimate in-ticket reads. This is a known cost of the fail-closed design. Mitigation: the Console can display a banner when FreeScout webhook is stale, warning support agents before they perform reads.
- The webhook-driven cache introduces a brief inconsistency window (seconds) between FreeScout state change and cache update. A support agent who reads immediately after ticket closure but before the webhook fires will receive a Path A audit row rather than Path B. This is an acceptable edge case; the ticket is closed, not resolved — the timing delta is a few seconds.
- Back-filled historical rows cannot have accurate
ticket_state_at_read(the state at the time of the original read is unknowable). Historical rows useticket_state_at_read = 'back_filled'; no notifications are fired for these rows.
Alternatives Considered
Single nightly batch notification (v1 design)
All Dim-3 reads notified in a nightly batch email. Simple, low infrastructure cost.
Rejected: not proactive for post-resolution reads. A customer who was browsed without authorization at 00:01 UTC is not informed until the following night. The invariant requires proactive notification; a 24-hour batch is retrospective.
Real-time per-action notification for all Dim-3 reads
Every Dim-3 read (including in-ticket support reads) fires an immediate notification.
Rejected: support reads within an active ticket create notification noise. A customer who opened a ticket receives a notification for each action a support agent takes — which may be dozens of reads during a complex investigation. This degrades the notification signal and may train customers to ignore the emails.
Ticket-close notification (notify when ticket closes, not when access occurs)
Aggregate all in-ticket reads into a single notification at ticket closure.
Considered but rejected for the post-resolution case: this model cannot produce a Path B notification at all, because the access that defines Path B occurs after the ticket is already closed. There is no ticket-close event to attach the notification to.
Opt-out for customers
Customers who find the notifications alarming can opt out.
Rejected: the notification is a GDPR transparency obligation, not a preference. An opt-out would allow customers to waive a privacy right — which is permissible under GDPR only if the waiver is freely given, specific, informed, and unambiguous. Given that the notification is also a security control (insider-threat detection), permitting opt-out would weaken the insider-threat signal. Security agent recommendation: no opt-out. Operator confirmed.