insert()` underneath. The * reverse contract (audit emit failure should NOT silently * dismantle the business operation) is the call site's * responsibility: most callers SHOULD log-and-continue on a * {@see \RuntimeException} from the writer rather than * re-raise, because losing one audit row is preferable to * losing the business operation it described. * * - Safe-summary scrubbing is the call site's responsibility, * not the writer's. The {@see AuditEventDraft} constructor * accepts the `safe_summary` verbatim and the writer hands * it to {@see \Affilun\Infrastructure\Persistence\AuditEventRecord} * unchanged. Call sites that touch secrets MUST run their * summary through {@see \Affilun\Application\Logs\LogMasker} * (or equivalent) BEFORE constructing the draft. This split * mirrors the {@see \Affilun\Infrastructure\Persistence\LogRepository} * contract for `affilun_logs` rows: the persistence boundary is * not the security boundary, the application layer is. * * Failure semantics: * * - Implementations MUST raise {@see \RuntimeException} (or a * subclass) when the underlying repository fails. Wrapping * in a more descriptive type (e.g. an audit-specific * `AuditWriteFailedException`) is permitted but not required * by the contract. * * - Validation failures on the supplied {@see AuditEventDraft} * surface from the draft constructor itself (the draft VO * normalizes / validates every field at construction time). * The writer therefore does NOT re-validate; it trusts the * draft's invariants and persists it verbatim. * * @package Affilun\Application\Audit */ declare(strict_types=1); namespace Affilun\Application\Audit; use Affilun\Infrastructure\Persistence\AuditEventRecord; use RuntimeException; /** * Phase 2 (KI-006 follow-up) audit-event writer contract. * * See the file-level docblock for the one-method scope, the * append-only invariant, the safe-summary responsibility split, * and the failure semantics. Production wiring is provided by * {@see RepositoryAuditEventWriter}; tests typically inject an * in-memory recording double. */ interface AuditEventWriter { /** * Persist the supplied audit-event draft and return the * persisted record carrying the database-assigned id. * * Implementations: * * - Stamp `createdAt` from the writer's injected * {@see \Affilun\Domain\Clock}, NOT from the * draft (the draft does not expose a timestamp slot — * the writer is the canonical observation boundary so * every audit row carries a clock the persistence layer * can trust). * * - Encode the draft into a fresh * {@see AuditEventRecord} (id = null) and persist it * through the underlying * {@see \Affilun\Infrastructure\Persistence\AuditEventRepository::save()}. * The §10.1 append-only invariant is enforced there; * callers do not need to hold the persisted record alive * unless they want to use the assigned id for follow-up * observability (e.g. include it in a logs entry). * * - Surface persistence failures as * {@see RuntimeException} so the caller can decide on * log-and-continue vs. escalate. Do NOT swallow the * exception inside the writer — the call site is the * correct boundary for "audit emit failed, but the * business operation succeeded; tolerate or escalate?". * * @param AuditEventDraft $draft Validated audit-event draft. * * @return AuditEventRecord Persisted record carrying the * assigned auto-increment id. * * @throws RuntimeException When the underlying repository * fails to persist the row. */ public function record( AuditEventDraft $draft ): AuditEventRecord; }