Skip to main content

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 nullable tenant_id (the team_invitations posture) means one BYPASSRLS pass over the TenantNotificationLogItem model reaches both tenant-stamped client rows and null-tenant operator rows — no per-tenant loop (unlike the strictly-tenant-scoped mirrors:prune-drift). Each configured class is drained in bounded id-batches (Postgres has no DELETE ... LIMIT); a per-run correlation_id is seeded into Context and per-class deleted counts go to Log::channel('audit'). Sanctioned out-of-bus by exact class in CqrsBoundaryTest (its own log model — the PruneResolvedDriftCommand precedent).
  • config('notification-log.retention') (root config/notification-log.php): default_days (null on ship)
    • classes (notification-FQCN ⇒ days map, EMPTY on ship). The stored notification_type is 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.


← Decision log index