D89 — Notification: responsibility-based operator recipient resolution + CTAs as plain data (RESOLVED, extends D47/D62/D69)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
Context. Operator notifications were addressed to a HARDCODED single recipient: both production
call sites (the dlp IncidentAlerter, the compliance EscalateCaseHandler) resolved one config-driven
static uuid (dlp.alerts.operator_notifiable_uuid / compliance.alerts.operator_notifiable_uuid) and
fell back to an audit-channel log line. And notifications were display-only — no way for the FE to
render an actionable button (Task 03, backend-followups).
Decision (recipient resolution). The notification module owns a recipient-resolution seam:
Contracts\ResolvesNotificationRecipients (recipientsFor(type): list<operator uuids>), default-bound
to Support\ResponsibilityBasedRecipientResolver — a ROLE-based resolver over
config('notification.responsibilities') (module config), mapping a notification type (exact key →
longest prefix.* wildcard → '*' catch-all) to central web-guard spatie role names; every ACTIVE,
non-deleted operator holding one is a recipient. The dispatcher grows
sendToResponsibleOperators(type, data, tenantId, actions): list<uuid> — one central row per
recipient, first uuid threaded into Context (D69; one command links one representative notification).
Empty resolution writes nothing — the CALLER owns its fallback, and both call sites keep the legacy
chain (responsibility → static config uuid → audit log), so shipped behavior is identical until the map
(EMPTY on ship — routing is an operational decision, not a code change) is populated. Rationale:
"who is on the hook for this alert type" is org structure, not domain logic — roles are the existing
operator org model (D24), and the seam keeps other resolution strategies (on-call schedules, per-tenant
routing) a rebind away. The resolver is a sanctioned out-of-bus model READER by exact class
(CqrsBoundaryTest rule #3 — the IncidentAlerter/BusFeatureGuard precedent).
Decision (actionable CTAs). A notification can carry actions — CTAs (label + typed target +
optional payload; DataObjects\NotificationActionData) persisted on a nullable actions jsonb column
of BOTH stores (notifications + operator_notifications) and echoed verbatim as an always-a-list
actions attribute on the client JSON:API resource. The target is PLAIN DATA keyed by
Enums\NotificationActionTargetType — url {href}, route {name, params} (an SPA route),
async_request {async_request_id} (the D83 poll uuid) — never a class reference, so a CTA pointing
at the async-requests poll adds NO module dependency edge (the ModuleBoundaryTest forbidden entry for
Modules\AsyncRequests stays). Actions are redaction-safe like data (D47's inbox posture) and are
deliberately NOT written to the spatie comms log's extra (the lean-extra rule). Clients declare them
via ClientNotification::inboxActions() (default []); operator sends pass $actions.
Also resolved in the same pass (authorization consistency, Task 03 §1). The claimed
authorize()-vs-permission inconsistency did NOT exist in the messages — all three notification
messages already followed the canonical D51 declared-permission pattern. The REAL residue was the
ROUTES: the three notification routes sat on the pre-seam
AuthorizationCoverageTest::ROUTES_WITHOUT_REQUIRES allow-list. They now declare ->requires(...)
(behavior-equivalent — the macro is the exported endpoint↔permission map, the bus gate stays the
enforcement) and the allow-list entries are dropped; NotificationAuthorizationConsistencyTest pins
the canonical pattern against regression.