Skip to main content

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\NotificationActionTargetTypeurl {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.


← Decision log index