= 0`). A value of `0` is the steady- * state "nothing to purge" outcome the §10.4 worker observes * on most ticks once the table is older than the retention * window — it is NOT an error and the worker still emits its * audit row so the observability surface can show "purge ran * and found nothing to do" rather than "purge skipped". * * Invariants enforced by the constructor: * * - `deleted >= 0` (a non-negative count is the only meaningful * value for a delete operation; negative inputs are coerced * defensively at the persistence boundary, but the VO also * refuses them so a hostile / mocked repository cannot * smuggle a bogus value into the audit payload). * * - `retentionDays >= 1` (the policy enforces a 30-day floor * itself; the VO accepts anything `>= 1` so tests that drive * the result directly do not have to construct a full policy * just to satisfy a transitive minimum). * * - `tickStartedAt` and `cutoff` are normalized to UTC so the * audit row this result feeds carries timestamps in the same * timezone the persistence layer stores them in. * * Mirrors the {@see \Affilun\Application\Sync\SyncWorkerTickResult} * shape (compact summary VO carried back to the surrounding * scheduler) so the §10.4 retention purge surface reuses the * same per-tick observability conventions the §11 sync worker * already established. * * @package Affilun\Application\Audit */ declare(strict_types=1); namespace Affilun\Application\Audit; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; /** * Immutable per-tick outcome carried back from * {@see AuditRetentionPurger::purge()}. * * See the file-level docblock for the field semantics, the * non-negative `deleted` invariant, and the role this value * object plays at the worker-to-scheduler observability seam. */ final class AuditRetentionPurgeResult { /** * UTC instant stamped at the top of the purge tick. * * Same value the {@see AuditRetentionPolicy::cutoffFrom()} * arithmetic consumed to derive {@see self::$cutoff}, so a * downstream observer can reconstruct the policy * computation without re-reading the clock. */ private DateTimeImmutable $tickStartedAt; /** * Strict-less-than UTC cutoff instant the persistence layer * compared row `created_at` values against. */ private DateTimeImmutable $cutoff; /** * Configured retention window in days at the moment of the * sweep. * * Echoed back from the {@see AuditRetentionPolicy} the * purger was holding when {@see AuditRetentionPurger::purge()} * ran, so the audit row recording the purge can carry the * policy state at the moment of the sweep even if the live * policy is later reconfigured. */ private int $retentionDays; /** * Number of rows the persistence layer reports as removed * during this tick (`>= 0`). */ private int $deleted; /** * Construct the result with explicit per-axis values. * * @param DateTimeImmutable $tickStartedAt UTC tick-start instant. * @param DateTimeImmutable $cutoff UTC strict-less-than cutoff. * @param int $retentionDays Retention window in days * (>= 1). * @param int $deleted Number of rows removed * (>= 0). * * @throws InvalidArgumentException When `$retentionDays` is * below 1 or `$deleted` is * negative. */ public function __construct( DateTimeImmutable $tickStartedAt, DateTimeImmutable $cutoff, int $retentionDays, int $deleted ) { if ( $retentionDays < 1 ) { $message = sprintf( 'AuditRetentionPurgeResult: retentionDays must be >= 1, got %d.', $retentionDays ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $deleted < 0 ) { $message = sprintf( 'AuditRetentionPurgeResult: deleted must be >= 0, got %d.', $deleted ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $utc = new DateTimeZone( 'UTC' ); $this->tickStartedAt = $tickStartedAt->setTimezone( $utc ); $this->cutoff = $cutoff->setTimezone( $utc ); $this->retentionDays = $retentionDays; $this->deleted = $deleted; } /** * UTC instant stamped at the top of the purge tick. */ public function tickStartedAt(): DateTimeImmutable { return $this->tickStartedAt; } /** * Strict-less-than UTC cutoff instant. */ public function cutoff(): DateTimeImmutable { return $this->cutoff; } /** * Configured retention window in days at the moment of the * sweep. */ public function retentionDays(): int { return $this->retentionDays; } /** * Number of rows removed during this tick (`>= 0`). */ public function deleted(): int { return $this->deleted; } /** * Whether the tick removed at least one row. * * Convenience predicate so the surrounding scheduler / * audit-emit path can elide "purge observed nothing" log * lines on idle ticks without having to inspect * {@see self::deleted()} directly. */ public function didDelete(): bool { return $this->deleted > 0; } }