events = $events; $this->policy = $policy; $this->clock = $clock; $this->auditWriter = $auditWriter; } /** * Configured retention policy. * * Exposed so diagnostics surfaces and tests can introspect * the policy snapshot without re-reading the constructor * arguments. */ public function policy(): AuditRetentionPolicy { return $this->policy; } /** * Drive one §10.4 retention purge sweep and return the * resulting tick summary. * * Stamps a single UTC "now" instant at the top of the tick, * derives the strict-less-than cutoff via * {@see AuditRetentionPolicy::cutoffFrom()}, calls * {@see AuditEventRepository::deleteOlderThan()} with the * cutoff, builds an {@see AuditRetentionPurgeResult} * carrying the policy snapshot + deleted count, and emits * one meta-audit row through the optional writer per the * §10.4 "purge 実行自体も audit または operational event * として記録してよい" rule. * * The meta-audit row is emitted even on an idle tick * (`deleted === 0`) so the audit trail carries the * canonical evidence that the retention policy actually ran * on a given day; an idle tick without an audit row is * indistinguishable from "the cron event never fired" when * reviewing the trail months later. * * Persistence-layer failures from * {@see AuditEventRepository::deleteOlderThan()} bubble out * unchanged; the meta-audit row is NOT emitted in that case * because the deletion may have partially succeeded and * the row count would be inaccurate. The surrounding cron * action handler is responsible for catching the throwable * and routing it to `error_log()`. * * Audit emit failures are swallowed inside * {@see self::emitPurgeAudit()} so the operator-visible * purge outcome (rows are gone, summary VO is returned) is * preserved even when the meta-audit emission fails. * * @throws RuntimeException When * {@see AuditEventRepository::deleteOlderThan()} * reports a driver error. */ public function purge(): AuditRetentionPurgeResult { $now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) ); $cutoff = $this->policy->cutoffFrom( $now ); $deleted = $this->events->deleteOlderThan( $cutoff ); $result = new AuditRetentionPurgeResult( $now, $cutoff, $this->policy->retentionDays(), $deleted ); $this->emitPurgeAudit( $result ); return $result; } /** * Emit a `compliance_relevant_action` audit row describing * the just-completed purge sweep. * * No-op when no audit writer was injected at construction * time. Failure to persist the row is swallowed per the * {@see AuditEventWriter} "tolerate audit emit failure" * contract — losing one meta-audit row is preferable to * derailing a successful purge. * * The draft uses {@see AuditEventDraft::forSystemAction()} * because the purge runs without a human actor (a daily * WP-Cron event, not an admin-triggered action). The * `safe_summary` carries a fixed-shape sentence with the * deleted count and retention window; no row content from * the purged audit rows is leaked into the meta-audit row * (which would defeat the §10.4 retention purpose by * preserving the very content the policy just removed). * * The `context_json` payload carries: * * - `cutoff` — ISO-8601 UTC timestamp the persistence * layer compared row `created_at` values against. * * - `deleted` — number of rows removed (`>= 0`). * * - `retention_days` — the policy snapshot at the moment * of the sweep, so the meta-audit row carries the * policy state even if a later reconfiguration changes * the live policy. * * @param AuditRetentionPurgeResult $result Purge outcome to * encode. */ private function emitPurgeAudit( AuditRetentionPurgeResult $result ): void { if ( $this->auditWriter === null ) { return; } try { $draft = AuditEventDraft::forSystemAction( self::EVENT_TYPE, self::AUDIT_KIND, self::ACTION, AuditEventDraft::RESULT_SUCCESS, sprintf( 'Audit retention purge removed %d row(s) older than %s (retention window: %d day(s)).', $result->deleted(), $result->cutoff()->format( DateTimeImmutable::ATOM ), $result->retentionDays() ), self::TARGET_TYPE, self::TARGET_ID, array(), array( 'cutoff' => $result->cutoff()->format( DateTimeImmutable::ATOM ), 'deleted' => $result->deleted(), 'retention_days' => $result->retentionDays(), ) ); $this->auditWriter->record( $draft ); } catch ( RuntimeException $unused ) { // Per the AuditEventWriter contract, audit emit // failure must not derail the surrounding business // outcome. The deletion has already happened by the // time we reach this branch, so the operator-visible // result (rows are gone, summary VO is returned) is // preserved even when the meta-audit emission fails. unset( $unused ); } } }