= {@see self::MINIMUM_RETENTION_DAYS}). * Defaults to * {@see self::DEFAULT_RETENTION_DAYS}. * * @throws InvalidArgumentException When `$retentionDays` is * below * {@see self::MINIMUM_RETENTION_DAYS}. */ public function __construct( int $retentionDays = self::DEFAULT_RETENTION_DAYS ) { if ( $retentionDays < self::MINIMUM_RETENTION_DAYS ) { $message = sprintf( 'AuditRetentionPolicy: retentionDays must be >= %d (spec §10.4 forbids treating' . ' audit rows as short-lived operational telemetry); got %d.', self::MINIMUM_RETENTION_DAYS, $retentionDays ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } $this->retentionDays = $retentionDays; } /** * Convenience factory producing a policy at the * spec-recommended 365-day default. * * Exposed so the cron service provider's composition seam * can stay readable (`AuditRetentionPolicy::default()`) * without spelling out the constructor argument. */ public static function default(): self { return new self( self::DEFAULT_RETENTION_DAYS ); } /** * Configured retention window in days. */ public function retentionDays(): int { return $this->retentionDays; } /** * Compute the strict-less-than cutoff instant from the * supplied "now" reference. * * Returns `$now - $retentionDays` normalized to UTC. The * persistence layer compares row `created_at` instants * strictly against the returned value — rows whose * `created_at` is strictly less than the cutoff are removed, * rows whose `created_at` equals the cutoff are preserved * (so a row landing on the boundary survives at least one * full sweep before disappearing on the next tick). * * Normalization to UTC is unconditional: even when `$now` is * already in UTC the returned value is a fresh * {@see DateTimeImmutable} so callers cannot accidentally * mutate the worker's stamped "now" through a shared * reference. * * @param DateTimeImmutable $now Worker-stamped reference * instant; typically * {@see \Affilun\Domain\Clock::now()} * at the top of the purge * tick. */ public function cutoffFrom( DateTimeImmutable $now ): DateTimeImmutable { $normalized = $now->setTimezone( new DateTimeZone( 'UTC' ) ); return $normalized->sub( new DateInterval( 'P' . $this->retentionDays . 'D' ) ); } }