D77 — Class-aware notification-log retention: safe-by-default per-class prune + the D31 floor clamp (RESOLVED, extends D70/D31)
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.
The notification_log_items retention deferral (docs/tracking/notification-log-retention.md, the Phase-5 N3
follow-up) is resolved as a mechanism-only landing: the class-aware prune MACHINERY ships, but no retention
window values are shipped — the routine map is empty on ship, so the command prunes NOTHING until legal/compliance
sign-off + WORM/archival of compliance comms (D31 prerequisites) are met and a window is populated (a config change,
not code).
The old stop-gap is removed: NotificationServiceProvider::register() no longer forces
config(['notification-log.prune_after_days' => 36500]) (~100y no-op). It is replaced by two guards:
notifications:prune-log(Modules\Notification\Console\Commands\PruneNotificationLog, scheduled daily,onOneServer()->withoutOverlapping()— the rails/provider-mirror D70 prune idiom). CENTRAL, single-pass: the table's nullabletenant_id(theteam_invitationsposture) means one BYPASSRLS pass over theTenantNotificationLogItemmodel reaches both tenant-stamped client rows and null-tenant operator rows — no per-tenant loop (unlike the strictly-tenant-scopedmirrors:prune-drift). Each configured class is drained in bounded id-batches (Postgres has noDELETE ... LIMIT); a per-runcorrelation_idis seeded intoContextand per-class deleted counts go toLog::channel('audit'). Sanctioned out-of-bus by exact class inCqrsBoundaryTest(its own log model — thePruneResolvedDriftCommandprecedent).config('notification-log.retention')(rootconfig/notification-log.php):default_days(nullon ship)classes(notification-FQCN ⇒ days map, EMPTY on ship). The storednotification_typeis the notification FQCN, so the map is keyed by class. Per configured class:effective = classes[$fqcn] ?? default_days;null⇒ retained indefinitely (skipped); an unlisted class is never pruned.
The floor double-guard. A class implementing Notifications\Contracts\ComplianceNotice (RFI/freeze/re-KYC/
rejection — e.g. ComplianceRfiNotification) is ALWAYS clamped UP to
max(effective, config('foundation.compliance_retention_floor_days')) (the D31 7-year floor, the single config
source), even if mis-listed with a shorter window — the same floor-clamp the D70 rails/provider-mirror prunes apply.
The model guard. TenantNotificationLogItem::prunable() is overridden to a never-matching query
(whereRaw('1 = 0')), so the package's global php artisan model:prune (which MassPrunables on prune_after_days)
can NEVER silently delete comms — all pruning funnels through notifications:prune-log.
Proven by PruneNotificationLogTest: a recent compliance row survives a routine prune; an old routine row is pruned
while a recent one is kept; the clamp retains an old compliance row mis-listed with a 1-day window; model:prune
deletes nothing; the central pass is cross-tenant and spares another tenant's compliance rows. Compliance
ratification of the exact per-class windows + storage-side WORM remain the prerequisites to populating the map.