# RunNotificationAuditRetentionSweep

## Permission Scope

auditConfig

## Overview

runNotificationAuditRetentionSweep is the system-only sweep command (the host app wires it to a daily cron) that anonymizes audit rows older than the configured TTL (default 90 days) across **both** delivery-audit tracks. For the `NotificationDeliveryAudit` (PERSONAL) track, rows past TTL have `errorDetail` cleared and any user-driven `occurredBy` replaced with the `TOMBSTONED` sentinel; the audit shell (`id`, `notificationId`, `eventType`, `occurredAt`, `errorClass`) is preserved so the compliance trail remains structurally complete. For the `DestinationDeliveryLog` (DESTINATION) track, rows past TTL have `providerResponse` and `failureReason` cleared while the structural shell (`id`, `sourceType`, `sourceId`, `targetType`, `targetId`, `channelId`, `status`, `attemptedAt`) is preserved. This is the same "anonymize, do not delete" stance that `anonymizeNotificationsForUser` follows.

The sweep is **set-based**, not per-row: it issues three conditional UPDATE statements (tombstone user-driven PERSONAL rows, clear `errorDetail` on system/tombstoned PERSONAL rows, clear free-form fields on DESTINATION rows), each scoped by the TTL cutoff and a not-yet-anonymized predicate. Already-anonymized rows are excluded by the WHERE predicates, so repeated passes are idempotent. A storage failure **aborts the transaction and propagates to the scheduler** — there is no per-row failure swallowing; the next scheduled run retries the same set. The command returns the anonymized row counts per track (`rowsAnonymized`, `destinationRowsAnonymized`). It is system-invocation only; there is no user-facing trigger.

## Business Rules

- Authorization is enforced by the command's permission gate on `notification:auditConfig` (or the command-level `notification:auditConfig:runNotificationAuditRetentionSweep`; the system scheduler / cron context); the command body assumes an authorized caller
- The TTL is taken from the optional `ttlDays` input; default is 90 days when no override is supplied
- `ttlDays` must be a positive integer (>= 1); zero, negative, fractional, or NaN values are rejected with `INVALID_RETENTION_TTL` before any row is touched
- PERSONAL-track selection: `NotificationDeliveryAudit` rows where `occurredAt < (now - TTL)` and not already anonymized; user-driven rows have `errorDetail` cleared and `occurredBy` tombstoned, system/tombstoned rows only have a remaining `errorDetail` cleared
- DESTINATION-track selection: `DestinationDeliveryLog` rows where `attemptedAt < (now - TTL)` and `providerResponse` or `failureReason` is still set; both fields are cleared
- Preserve `id`, `notificationId`, `eventType`, `occurredAt`, and `errorClass` on audit rows; preserve the structural shell on destination logs
- Already-anonymized rows are excluded by the WHERE predicates, so subsequent passes are no-ops (idempotent)
- A storage failure aborts the transaction and surfaces to the scheduler; nothing is silently swallowed
- The sweep does **not** delete rows or alter parent Notifications
- The command returns `{ rowsAnonymized, destinationRowsAnonymized }`

## Process Flow

```mermaid
flowchart TD
    A[Scheduler invokes sweep] --> D{ttlDays integer >= 1 or absent?}
    D -->|No| E[Return INVALID_RETENTION_TTL]
    D -->|Yes| F[Resolve cutoff = now - ttlDays, default 90 days]
    F --> G[UPDATE NotificationDeliveryAudit: user-driven rows past cutoff -> errorDetail=null, occurredBy=TOMBSTONED]
    G --> H[UPDATE NotificationDeliveryAudit: system/tombstoned rows past cutoff with errorDetail -> errorDetail=null]
    H --> I[UPDATE DestinationDeliveryLog: rows past cutoff with providerResponse or failureReason -> both null]
    I --> J{Any statement failed?}
    J -->|Yes| K[Transaction aborts; error propagates to scheduler]
    J -->|No| L[Return rowsAnonymized and destinationRowsAnonymized counts]
```

## External Dependencies

- None — the TTL is supplied via the optional `ttlDays` input (default 90 days)

## Error Scenarios

- **INVALID_RETENTION_TTL**: ttlDays is not a positive integer (zero, negative, fractional, or NaN)

## Test Cases

- anonymizes user-driven NotificationDeliveryAudit rows older than the TTL with a set-based update that clears errorDetail and tombstones occurredBy
- scopes the tombstone update by the cutoff and excludes rows whose occurredBy is already system / TOMBSTONED
- clears errorDetail on system / tombstoned rows older than the TTL without rewriting occurredBy
- preserves audit row id, notificationId, eventType, occurredAt, and errorClass
- respects the ttlDays override when computing the cutoff
- rejects a zero, negative, fractional, or NaN ttlDays with INVALID_RETENTION_TTL without touching any row
- reports rowsAnonymized=0 when no rows are past the TTL (the WHERE bounds exclude un-aged rows)
- is idempotent across sweep passes: already-anonymized rows are excluded by the WHERE predicates
- does not delete any audit rows or alter parent Notifications
- propagates a storage failure instead of silently swallowing it (the transaction aborts)
- a BOUNCED webhook arriving after the sweep on an anonymized Notification still writes its audit row and updates deliveryStatus
- anonymizes DestinationDeliveryLog rows older than the TTL by clearing providerResponse and failureReason while preserving the structural shell
- does not anonymize DestinationDeliveryLog rows newer than the TTL or already anonymized
- reports the DestinationDeliveryLog rows anonymized separately from the NotificationDeliveryAudit count
