diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 52499d2b..6bbdcb0a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,8 +3,9 @@ name: CI
on:
push:
branches: [main]
- pull_request:
- branches: [main]
+ # Every pull request target, including immutable stack branches, receives the
+ # same exact-head, synthetic-merge, and buyer-readiness acceptance evidence.
+ pull_request: {}
permissions:
contents: read
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 099cab55..1cd82131 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다.
+- `RECEIPT_V1` append-only artifact-deletion receipt foundation을 추가했습니다. 영구 `job_id`, tenant, artifact SHA-256, idempotency request, privacy-safe audit correlation, 단조 상태와 시각, controlled failure code를 하나의 불변 증거로 묶고 standalone in-memory 및 restart-replay file adapter를 같은 `ArtifactDeletionReceiptStore` 계약으로 제공합니다. HTTP 삭제·metadata tombstone·cleanup worker의 원자적 통합은 issue #263의 후속 slice로 유지합니다.
- **관리자용 단건 작업 삭제 및 재시도 API 추가**
- 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다.
- 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다.
@@ -25,6 +26,7 @@
### Security
- `GET /api/v1/convert/jobs/{jobId}/download`가 리소스 조회 전에 전용 `artifact:read` 권한을 검증하고, PDF 저장소 접근 전에 작업의 tenant 소유권을 확인하도록 강화했습니다. `job:read`만으로는 문서 바이트를 읽을 수 없으며, 인증 누락·권한 누락·교차 tenant UUID 접근은 각각 fail closed 처리되고 교차 tenant 요청은 리소스 존재를 숨기는 `404`를 반환합니다.
+- Deletion receipt ledger는 각 transition snapshot을 16 KiB 이하 strict UTF-8 `RECEIPT_V1` record로 검증하고 append 후 `FileChannel.force(true)`를 완료하기 전 durable 상태를 반환하지 않습니다. Record terminator는 host-independent fixed LF로 고정하고 commit delimiter로 취급하므로 non-empty unterminated final tail은 crash 또는 torn append의 미확정 증거로 간주해 startup에서 fail closed 처리하고 replay하지 않습니다. Replay는 `attempt_count`와 `last_attempt_at`의 동시 존재, 시각 순서, requested/tombstoned 상태의 zero-attempt 계약, retry/completion의 이전 attempt evidence 보존을 검증하며 pending-to-failed 전이만 count 증가를 허용합니다. malformed Base64URL, invalid timestamp/state/count, immutable identity conflict, non-monotonic time, unsafe failure detail, oversized line도 거부합니다. 이 증거는 아직 cleanup 완료를 의미하지 않으며 issue #263의 transactional outbox와 exact-generation worker가 통합될 때까지 incomplete cleanup finding을 유지합니다.
- Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다.
- Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다.
- Maven XML 테스트 보고서 검증기는 파일당 16 MiB 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다.
diff --git a/docs/security/2026-08-06-durable-artifact-deletion-receipts.md b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md
new file mode 100644
index 00000000..5098e6d5
--- /dev/null
+++ b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md
@@ -0,0 +1,169 @@
+# Durable artifact-deletion receipt boundary
+
+- **Status:** Accepted foundation; HTTP mutation and cleanup-worker integration remain future slices.
+- **Decision date:** 2026-08-06
+- **Owner issue:** #263
+- **Format version:** `RECEIPT_V1`
+
+## Decision
+
+Clearfolio records each accepted artifact-deletion lifecycle as immutable,
+versioned receipt snapshots. The reference adapter is an append-only UTF-8 ledger
+that forces each snapshot to storage before returning. A standalone caller may
+use the in-memory constructor, while the Spring application defaults to
+`data/artifact-deletion-receipts.log` so pending evidence can be replayed after a
+process restart.
+
+This foundation does **not** yet change the administrative DELETE response, move
+job tombstoning and receipt creation into one transaction, revoke signed links,
+or run an artifact-cleanup worker. Those are separate bounded slices under
+issue `#263`. Until they integrate, the existing delete path remains best effort
+and the CodeRabbit incomplete-cleanup finding must remain open.
+
+## Immutable receipt identity
+
+One receipt identity contains:
+
+- `request_id`: deletion idempotency identifier;
+- `tenant_id`: tenant that owned the conversion job;
+- `job_id`: permanently reserved conversion-job identifier;
+- `artifact_checksum`: lowercase SHA-256 digest for the exact artifact bytes;
+- `audit_correlation_id`: privacy-safe correlation value, never a raw subject,
+ tenant secret, token, filename, or document value;
+- `requested_at`: durable request time.
+
+A repeated request returns the existing object only when every immutable field
+matches. A different request, tenant, artifact digest, audit correlation, or
+request time for the same `job_id` fails closed. This complements #268's
+permanent UUID reservation and prevents cleanup work from being rebound to a
+new tenant or artifact generation.
+
+## Monotonic lifecycle
+
+```mermaid
+stateDiagram-v2
+ [*] --> DELETION_REQUESTED
+ DELETION_REQUESTED --> METADATA_TOMBSTONED
+ METADATA_TOMBSTONED --> ARTIFACT_CLEANUP_PENDING
+ ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_COMPLETED
+ ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_FAILED
+ ARTIFACT_CLEANUP_FAILED --> ARTIFACT_CLEANUP_PENDING
+ ARTIFACT_CLEANUP_COMPLETED --> [*]
+```
+
+Every snapshot records `state_changed_at`. Time cannot move backward relative to
+the previous durable transition. `ARTIFACT_CLEANUP_COMPLETED` is terminal.
+Failures increment `attempt_count`, record `last_attempt_at`, and accept only a
+controlled lowercase code matching `[a-z0-9_]{1,64}`. Exception messages,
+storage paths, raw identifiers, stack traces, and document metadata are not
+valid failure codes.
+
+Attempt evidence is internally consistent and immutable across replay:
+
+- `attempt_count == 0` requires absent `last_attempt_at`;
+- `attempt_count > 0` requires a `last_attempt_at` no later than
+ `state_changed_at`;
+- requested and metadata-tombstoned receipts cannot claim cleanup attempts;
+- only pending-to-failed increments the count and advances `last_attempt_at`;
+- retry-pending and completed snapshots preserve the prior count and latest
+ attempt instant exactly.
+
+A replayed snapshot that erases, rewrites, advances, or invents attempt evidence
+outside those transitions fails closed.
+
+## Persistence and replay
+
+The reference file adapter applies the following contract:
+
+1. serialize one complete `RECEIPT_V1` snapshot;
+2. encode variable text fields as Base64URL and separate fields with tabs;
+3. reject records longer than 16 KiB during replay;
+4. write the full record and a fixed ASCII LF (`0x0A`) terminator through one
+ append-only file channel, treating LF as the record commit delimiter;
+5. call `FileChannel.force(true)` before exposing the transition as durable;
+6. replay only delimiter-committed records with bounded, strict UTF-8 decoding;
+7. reject any non-empty unterminated final tail instead of interpreting a
+ potentially torn or pre-force append as durable evidence;
+8. validate record shape, immutable identity, state timestamps, attempt evidence,
+ and legal predecessor-to-successor transitions;
+9. fail application construction on malformed, conflicting, oversized,
+ unterminated, or non-monotonic evidence.
+
+A missing ledger file means an empty store. A malformed existing file or
+unterminated final record is not silently skipped, truncated, or replayed.
+Startup does not mutate audit evidence as an implicit recovery action; operators
+must preserve the original bytes and use a controlled, reviewable recovery
+procedure. Completed receipts are retained for audit and idempotency but
+excluded from the pending-work view.
+
+## Modular and MSA contract
+
+`ArtifactDeletionReceiptStore` is the versioned service boundary. An external
+PostgreSQL, event-store, or message-broker adapter may replace the file ledger,
+but it must preserve:
+
+- one permanently reserved `job_id` lifecycle;
+- exact immutable request identity;
+- tenant and artifact-digest binding;
+- monotonic state and time;
+- idempotent duplicate requests;
+- controlled privacy-safe failure evidence;
+- restart-replay of pending and failed work;
+- terminal completion retention;
+- fail-closed conflict behavior.
+
+Future database objects must use descriptive two-or-more-word `snake_case`
+names, including `deletion_request`, `deletion_receipt`,
+`artifact_cleanup_task`, `job_tombstone`, and `audit_event`.
+
+## Required next slices
+
+### Atomic tombstone and outbox
+
+Create the receipt, metadata tombstone, and cleanup task in one durable
+transaction. A crash must not leave a tombstone without a replayable task or a
+task without an authorized tombstone.
+
+### Exact-generation cleanup worker
+
+The worker must revalidate tenant, `job_id`, artifact checksum or storage
+generation, receipt state, and revocation state before deletion. Stale receipts
+must be rejected rather than deleting newer bytes.
+
+### Truthful API and accessible UI
+
+The product must distinguish accepted, cleanup pending, cleanup failed, and
+completed states. It must never return a completed deletion while confidential
+artifact bytes or valid signed links remain available.
+
+## Verification requirements
+
+- exact duplicate request object identity;
+- conflicting same-job requests fail closed;
+- every legal and illegal transition;
+- nondecreasing transition timestamps;
+- internally consistent and transition-preserved attempt evidence;
+- controlled failure-code validation;
+- attempt-count persistence across retries and restart;
+- strict UTF-8, malformed-line, oversized-line, conflicting-replay, and
+ unterminated-final-tail rejection;
+- fixed host-independent LF record delimiters;
+- completed receipts excluded from pending work but retained after restart;
+- production line and branch coverage of 100%;
+- complete beginner-readable public Javadocs;
+- exact-head CI, Security Scan, SAST, fuzzing, independent review, and protected
+ merge evidence before integration.
+
+## References
+
+Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110;
+STD 97). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9110
+
+National Institute of Standards and Technology. (2024). *Protecting controlled
+unclassified information in nonfederal systems and organizations* (NIST Special
+Publication 800-171, Revision 3). U.S. Department of Commerce.
+https://doi.org/10.6028/NIST.SP.800-171r3
+
+OWASP Foundation. (2023). *API1:2023 broken object level authorization*. In
+*OWASP API Security Top 10—2023*.
+https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py
new file mode 100644
index 00000000..8a3af8b7
--- /dev/null
+++ b/scripts/test_ci_workflow_stack_coverage.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml"
+
+
+def test_ci_runs_for_every_pull_request_base() -> None:
+ """Stacked pull requests must receive the same exact-head CI as main-bound PRs."""
+ workflow = CI_WORKFLOW.read_text(encoding="utf-8")
+
+ assert " pull_request: {}" in workflow
+ assert " pull_request:\n branches: [main]" not in workflow
+
+
+def test_ci_preserves_exact_head_and_synthetic_merge_evidence() -> None:
+ """Broadening PR coverage must not weaken exact-head or merge verification."""
+ workflow = CI_WORKFLOW.read_text(encoding="utf-8")
+
+ exact_head_expression = "github.event.pull_request.head.sha || github.sha"
+ assert workflow.count(exact_head_expression) >= 4
+ assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' in workflow
+ assert "name: Maven merge compatibility" in workflow
+ assert "name: Buyer-readiness script tests" in workflow
diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java
new file mode 100644
index 00000000..b747da00
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java
@@ -0,0 +1,447 @@
+package com.clearfolio.viewer.lifecycle;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.time.DateTimeException;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.UnaryOperator;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Append-only durable store for tenant-bound artifact-deletion receipts.
+ *
+ *
The file-backed mode writes a complete immutable receipt snapshot for each
+ * legal transition and forces the file channel before returning. Startup replay
+ * uses bounded strict UTF-8 lines and validates immutable identity, timestamps,
+ * and monotonic transitions before exposing pending work. An empty path selects
+ * the standalone in-memory adapter.
+ */
+@Repository
+public class ArtifactDeletionLedger implements ArtifactDeletionReceiptStore {
+
+ static final int MAX_LEDGER_LINE_BYTES = 16 * 1024;
+
+ private static final String RECEIPT = "RECEIPT_V1";
+ private static final String NULL_FIELD = "-";
+ private static final int FIELD_COUNT = 13;
+ private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
+ private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
+ private static final Comparator RECEIPT_ORDER = Comparator
+ .comparing(ArtifactDeletionReceipt::requestedAt)
+ .thenComparing(receipt -> receipt.jobId().toString());
+
+ private final ConcurrentMap receiptsByJobId = new ConcurrentHashMap<>();
+ private final Path ledgerPath;
+
+ /**
+ * Creates a standalone in-memory deletion ledger.
+ */
+ public ArtifactDeletionLedger() {
+ this((Path) null);
+ }
+
+ /**
+ * Creates a deletion ledger with optional append-only file persistence.
+ *
+ * @param configuredPath configured ledger path, or blank for in-memory mode
+ */
+ @Autowired
+ public ArtifactDeletionLedger(
+ @Value("${clearfolio.artifact-deletion-ledger.path:data/artifact-deletion-receipts.log}")
+ String configuredPath
+ ) {
+ this(pathOf(configuredPath));
+ }
+
+ ArtifactDeletionLedger(Path ledgerPath) {
+ this.ledgerPath = ledgerPath;
+ load();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public synchronized ArtifactDeletionReceipt request(
+ UUID requestId,
+ String tenantId,
+ UUID jobId,
+ String artifactChecksum,
+ String auditCorrelationId,
+ Instant requestedAt
+ ) {
+ ArtifactDeletionReceipt requested = new ArtifactDeletionReceipt(
+ requestId,
+ tenantId,
+ jobId,
+ artifactChecksum,
+ auditCorrelationId,
+ requestedAt,
+ requestedAt,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ 0,
+ null,
+ null,
+ null
+ );
+ ArtifactDeletionReceipt existing = receiptsByJobId.get(requested.jobId());
+ if (existing != null) {
+ if (existing.hasSameIdentity(requested)) {
+ return existing;
+ }
+ throw conflictingReceipt();
+ }
+
+ append(requested);
+ receiptsByJobId.put(requested.jobId(), requested);
+ return requested;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ArtifactDeletionReceipt markMetadataTombstoned(UUID jobId, Instant transitionedAt) {
+ return transition(jobId, current -> current.markMetadataTombstoned(transitionedAt));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ArtifactDeletionReceipt markCleanupPending(UUID jobId, Instant transitionedAt) {
+ return transition(jobId, current -> current.markCleanupPending(transitionedAt));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ArtifactDeletionReceipt recordCleanupFailure(
+ UUID jobId,
+ String failureCode,
+ Instant attemptedAt
+ ) {
+ return transition(
+ jobId,
+ current -> current.recordCleanupFailure(failureCode, attemptedAt)
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ArtifactDeletionReceipt markCleanupCompleted(UUID jobId, Instant completedAt) {
+ return transition(jobId, current -> current.markCleanupCompleted(completedAt));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Optional findByJobId(UUID jobId) {
+ if (jobId == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(receiptsByJobId.get(jobId));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List pendingReceipts() {
+ return receiptsByJobId.values().stream()
+ .filter(receipt -> !receipt.isCompleted())
+ .sorted(RECEIPT_ORDER)
+ .toList();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int pendingCount() {
+ return Math.toIntExact(receiptsByJobId.values().stream()
+ .filter(receipt -> !receipt.isCompleted())
+ .count());
+ }
+
+ private synchronized ArtifactDeletionReceipt transition(
+ UUID jobId,
+ UnaryOperator transition
+ ) {
+ UUID requiredJobId = Objects.requireNonNull(jobId, "jobId");
+ ArtifactDeletionReceipt current = receiptsByJobId.get(requiredJobId);
+ if (current == null) {
+ throw missingReceipt();
+ }
+ ArtifactDeletionReceipt updated = Objects.requireNonNull(
+ transition.apply(current),
+ "updatedReceipt"
+ );
+ if (!current.hasSameIdentity(updated)) {
+ throw conflictingReceipt();
+ }
+ append(updated);
+ receiptsByJobId.put(requiredJobId, updated);
+ return updated;
+ }
+
+ private void load() {
+ if (ledgerPath == null) {
+ return;
+ }
+ try (InputStream input = new BufferedInputStream(Files.newInputStream(ledgerPath))) {
+ readBoundedLines(input);
+ } catch (java.nio.file.NoSuchFileException exception) {
+ // A missing ledger represents an empty durable store.
+ } catch (IOException exception) {
+ throw new IllegalStateException("artifact deletion ledger cannot be loaded", exception);
+ }
+ }
+
+ private void readBoundedLines(InputStream input) throws IOException {
+ ByteArrayOutputStream line = new ByteArrayOutputStream();
+ int currentByte;
+ while ((currentByte = input.read()) != -1) {
+ if (currentByte == '\n') {
+ replayBytes(line.toByteArray());
+ line.reset();
+ continue;
+ }
+ if (line.size() >= MAX_LEDGER_LINE_BYTES) {
+ throw oversizedLine();
+ }
+ line.write(currentByte);
+ }
+ if (line.size() > 0) {
+ throw invalidLine();
+ }
+ }
+
+ private void replayBytes(byte[] bytes) {
+ int length = bytes.length;
+ if (length > 0 && bytes[length - 1] == '\r') {
+ length--;
+ }
+ if (length == 0) {
+ throw invalidLine();
+ }
+ replayLine(strictUtf8(Arrays.copyOf(bytes, length)));
+ }
+
+ private void replayLine(String line) {
+ ArtifactDeletionReceipt replayed = parse(line);
+ ArtifactDeletionReceipt current = receiptsByJobId.get(replayed.jobId());
+ if (current == null) {
+ if (replayed.state() != ArtifactDeletionState.DELETION_REQUESTED) {
+ throw invalidLine();
+ }
+ receiptsByJobId.put(replayed.jobId(), replayed);
+ return;
+ }
+ if (!current.hasSameIdentity(replayed)) {
+ throw invalidLine();
+ }
+ validateReplayTransition(current, replayed);
+ receiptsByJobId.put(replayed.jobId(), replayed);
+ }
+
+ private void append(ArtifactDeletionReceipt receipt) {
+ if (ledgerPath == null) {
+ return;
+ }
+ String serialized = serialize(receipt);
+ byte[] bytes = (serialized + "\n").getBytes(StandardCharsets.UTF_8);
+ try {
+ Path absolutePath = ledgerPath.toAbsolutePath();
+ Files.createDirectories(absolutePath.getParent());
+ try (FileChannel channel = FileChannel.open(
+ absolutePath,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.APPEND
+ )) {
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ channel.force(true);
+ }
+ } catch (IOException exception) {
+ throw new IllegalStateException("artifact deletion ledger cannot be written", exception);
+ }
+ }
+
+ private static ArtifactDeletionReceipt parse(String line) {
+ try {
+ String[] fields = line.split("\\t", -1);
+ if (fields.length != FIELD_COUNT || !RECEIPT.equals(fields[0])) {
+ throw invalidLine();
+ }
+ return new ArtifactDeletionReceipt(
+ UUID.fromString(fields[1]),
+ decodeRequired(fields[2]),
+ UUID.fromString(fields[3]),
+ decodeRequired(fields[4]),
+ decodeRequired(fields[5]),
+ Instant.parse(fields[6]),
+ Instant.parse(fields[7]),
+ ArtifactDeletionState.valueOf(fields[8]),
+ Integer.parseInt(fields[9]),
+ optionalInstant(fields[10]),
+ optionalInstant(fields[11]),
+ decodeOptional(fields[12])
+ );
+ } catch (IllegalArgumentException | DateTimeException exception) {
+ throw invalidLine(exception);
+ }
+ }
+
+ private static String serialize(ArtifactDeletionReceipt receipt) {
+ return String.join(
+ "\t",
+ RECEIPT,
+ receipt.requestId().toString(),
+ encode(receipt.tenantId()),
+ receipt.jobId().toString(),
+ encode(receipt.artifactChecksum()),
+ encode(receipt.auditCorrelationId()),
+ receipt.requestedAt().toString(),
+ receipt.stateChangedAt().toString(),
+ receipt.state().name(),
+ Integer.toString(receipt.attemptCount()),
+ optionalInstant(receipt.lastAttemptAt()),
+ optionalInstant(receipt.completedAt()),
+ encodeOptional(receipt.failureCode())
+ );
+ }
+
+ private static void validateReplayTransition(
+ ArtifactDeletionReceipt current,
+ ArtifactDeletionReceipt replayed
+ ) {
+ if (replayed.stateChangedAt().isBefore(current.stateChangedAt())) {
+ throw invalidLine();
+ }
+ boolean valid = switch (current.state()) {
+ case DELETION_REQUESTED -> replayed.state() == ArtifactDeletionState.METADATA_TOMBSTONED
+ && hasSameAttemptEvidence(current, replayed);
+ case METADATA_TOMBSTONED -> replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING
+ && hasSameAttemptEvidence(current, replayed);
+ case ARTIFACT_CLEANUP_PENDING -> (
+ replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED
+ && hasSameAttemptEvidence(current, replayed)
+ ) || (
+ replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED
+ && replayed.attemptCount() == current.attemptCount() + 1
+ );
+ case ARTIFACT_CLEANUP_FAILED -> replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING
+ && hasSameAttemptEvidence(current, replayed);
+ case ARTIFACT_CLEANUP_COMPLETED -> false;
+ };
+ if (!valid) {
+ throw invalidLine();
+ }
+ }
+
+ private static boolean hasSameAttemptEvidence(
+ ArtifactDeletionReceipt current,
+ ArtifactDeletionReceipt replayed
+ ) {
+ return replayed.attemptCount() == current.attemptCount()
+ && Objects.equals(replayed.lastAttemptAt(), current.lastAttemptAt());
+ }
+
+ private static String strictUtf8(byte[] bytes) {
+ try {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes))
+ .toString();
+ } catch (CharacterCodingException exception) {
+ throw invalidLine(exception);
+ }
+ }
+
+ private static String encode(String value) {
+ return ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String encodeOptional(String value) {
+ return value == null ? NULL_FIELD : encode(value);
+ }
+
+ private static String decodeRequired(String field) {
+ String value = decodeOptional(field);
+ if (value == null || value.isBlank()) {
+ throw invalidLine();
+ }
+ return value;
+ }
+
+ private static String decodeOptional(String field) {
+ if (NULL_FIELD.equals(field)) {
+ return null;
+ }
+ return strictUtf8(DECODER.decode(field));
+ }
+
+ private static String optionalInstant(Instant value) {
+ return value == null ? NULL_FIELD : value.toString();
+ }
+
+ private static Instant optionalInstant(String value) {
+ return NULL_FIELD.equals(value) ? null : Instant.parse(value);
+ }
+
+ private static Path pathOf(String value) {
+ String normalized = value == null ? null : value.strip();
+ return normalized == null || normalized.isEmpty() ? null : Path.of(normalized);
+ }
+
+ private static IllegalStateException conflictingReceipt() {
+ return new IllegalStateException("artifact deletion receipt conflicts with an existing lifecycle");
+ }
+
+ private static IllegalStateException missingReceipt() {
+ return new IllegalStateException("artifact deletion receipt not found");
+ }
+
+ private static IllegalStateException oversizedLine() {
+ return new IllegalStateException("artifact deletion ledger line exceeds the configured bound");
+ }
+
+ private static IllegalStateException invalidLine() {
+ return new IllegalStateException("artifact deletion ledger contains an invalid line");
+ }
+
+ private static IllegalStateException invalidLine(Throwable cause) {
+ return new IllegalStateException("artifact deletion ledger contains an invalid line", cause);
+ }
+}
diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java
new file mode 100644
index 00000000..6e3893e9
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java
@@ -0,0 +1,276 @@
+package com.clearfolio.viewer.lifecycle;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+/**
+ * Immutable evidence for one tenant-bound artifact-deletion lifecycle.
+ *
+ * @param requestId idempotency identifier supplied for the deletion request
+ * @param tenantId tenant that owned the deleted conversion job
+ * @param jobId permanently reserved conversion-job identifier
+ * @param artifactChecksum lowercase SHA-256 digest binding cleanup to one artifact generation
+ * @param auditCorrelationId privacy-safe identifier joining lifecycle audit evidence
+ * @param requestedAt instant when the deletion request became durable
+ * @param stateChangedAt instant when the current state became durable
+ * @param state current monotonic lifecycle state
+ * @param attemptCount number of failed cleanup attempts
+ * @param lastAttemptAt instant of the latest failed cleanup attempt, when any
+ * @param completedAt instant when cleanup became terminally complete, when any
+ * @param failureCode controlled non-sensitive failure code, when cleanup failed
+ */
+public record ArtifactDeletionReceipt(
+ UUID requestId,
+ String tenantId,
+ UUID jobId,
+ String artifactChecksum,
+ String auditCorrelationId,
+ Instant requestedAt,
+ Instant stateChangedAt,
+ ArtifactDeletionState state,
+ int attemptCount,
+ Instant lastAttemptAt,
+ Instant completedAt,
+ String failureCode
+) {
+
+ private static final int MAX_IDENTIFIER_LENGTH = 256;
+ private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}");
+ private static final Pattern FAILURE_CODE_PATTERN = Pattern.compile("[a-z0-9_]{1,64}");
+
+ /**
+ * Validates one immutable receipt snapshot.
+ */
+ public ArtifactDeletionReceipt {
+ requestId = Objects.requireNonNull(requestId, "requestId");
+ tenantId = requireText(tenantId, "tenantId");
+ jobId = Objects.requireNonNull(jobId, "jobId");
+ artifactChecksum = requireChecksum(artifactChecksum);
+ auditCorrelationId = requireText(auditCorrelationId, "auditCorrelationId");
+ requestedAt = Objects.requireNonNull(requestedAt, "requestedAt");
+ stateChangedAt = Objects.requireNonNull(stateChangedAt, "stateChangedAt");
+ state = Objects.requireNonNull(state, "state");
+ if (stateChangedAt.isBefore(requestedAt)) {
+ throw new IllegalArgumentException("stateChangedAt must not precede requestedAt");
+ }
+ if (attemptCount < 0) {
+ throw new IllegalArgumentException("attemptCount must not be negative");
+ }
+ failureCode = normalizeOptional(failureCode);
+ validateStateFields(
+ requestedAt,
+ stateChangedAt,
+ state,
+ attemptCount,
+ lastAttemptAt,
+ completedAt,
+ failureCode
+ );
+ }
+
+ /**
+ * Returns whether artifact cleanup completed successfully.
+ *
+ * @return true only for the terminal completed state
+ */
+ public boolean isCompleted() {
+ return state == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED;
+ }
+
+ /**
+ * Returns whether this receipt represents the same immutable request.
+ *
+ * @param other candidate receipt identity
+ * @return true when all immutable identity fields match
+ */
+ public boolean hasSameIdentity(ArtifactDeletionReceipt other) {
+ return other != null
+ && requestId.equals(other.requestId)
+ && tenantId.equals(other.tenantId)
+ && jobId.equals(other.jobId)
+ && artifactChecksum.equals(other.artifactChecksum)
+ && auditCorrelationId.equals(other.auditCorrelationId)
+ && requestedAt.equals(other.requestedAt);
+ }
+
+ ArtifactDeletionReceipt markMetadataTombstoned(Instant transitionedAt) {
+ requireState(ArtifactDeletionState.DELETION_REQUESTED);
+ return snapshot(
+ ArtifactDeletionState.METADATA_TOMBSTONED,
+ requireForwardTime(transitionedAt),
+ attemptCount,
+ lastAttemptAt,
+ null,
+ null
+ );
+ }
+
+ ArtifactDeletionReceipt markCleanupPending(Instant transitionedAt) {
+ if (state != ArtifactDeletionState.METADATA_TOMBSTONED
+ && state != ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED) {
+ throw invalidTransition();
+ }
+ return snapshot(
+ ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING,
+ requireForwardTime(transitionedAt),
+ attemptCount,
+ lastAttemptAt,
+ null,
+ null
+ );
+ }
+
+ ArtifactDeletionReceipt recordCleanupFailure(String controlledFailureCode, Instant attemptedAt) {
+ requireState(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING);
+ Instant requiredAttemptedAt = requireForwardTime(attemptedAt);
+ return snapshot(
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED,
+ requiredAttemptedAt,
+ Math.addExact(attemptCount, 1),
+ requiredAttemptedAt,
+ null,
+ requireFailureCode(controlledFailureCode)
+ );
+ }
+
+ ArtifactDeletionReceipt markCleanupCompleted(Instant completionInstant) {
+ requireState(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING);
+ Instant requiredCompletionInstant = requireForwardTime(completionInstant);
+ return snapshot(
+ ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED,
+ requiredCompletionInstant,
+ attemptCount,
+ lastAttemptAt,
+ requiredCompletionInstant,
+ null
+ );
+ }
+
+ private ArtifactDeletionReceipt snapshot(
+ ArtifactDeletionState nextState,
+ Instant nextStateChangedAt,
+ int nextAttemptCount,
+ Instant nextLastAttemptAt,
+ Instant nextCompletedAt,
+ String nextFailureCode
+ ) {
+ return new ArtifactDeletionReceipt(
+ requestId,
+ tenantId,
+ jobId,
+ artifactChecksum,
+ auditCorrelationId,
+ requestedAt,
+ nextStateChangedAt,
+ nextState,
+ nextAttemptCount,
+ nextLastAttemptAt,
+ nextCompletedAt,
+ nextFailureCode
+ );
+ }
+
+ private Instant requireForwardTime(Instant candidate) {
+ Instant requiredCandidate = Objects.requireNonNull(candidate, "transitionedAt");
+ if (requiredCandidate.isBefore(stateChangedAt)) {
+ throw new IllegalArgumentException("stateChangedAt must not precede the prior transition");
+ }
+ return requiredCandidate;
+ }
+
+ private void requireState(ArtifactDeletionState requiredState) {
+ if (state != requiredState) {
+ throw invalidTransition();
+ }
+ }
+
+ private static void validateStateFields(
+ Instant currentRequestedAt,
+ Instant currentStateChangedAt,
+ ArtifactDeletionState currentState,
+ int currentAttemptCount,
+ Instant currentLastAttemptAt,
+ Instant currentCompletedAt,
+ String currentFailureCode
+ ) {
+ if (currentLastAttemptAt != null && currentLastAttemptAt.isBefore(currentRequestedAt)) {
+ throw new IllegalArgumentException("lastAttemptAt must not precede requestedAt");
+ }
+ if (currentLastAttemptAt != null && currentLastAttemptAt.isAfter(currentStateChangedAt)) {
+ throw new IllegalArgumentException("lastAttemptAt must not follow stateChangedAt");
+ }
+ boolean hasAttempts = currentAttemptCount > 0;
+ if (hasAttempts != (currentLastAttemptAt != null)) {
+ throw new IllegalArgumentException("cleanup attempt evidence is inconsistent");
+ }
+ if (currentState == ArtifactDeletionState.DELETION_REQUESTED) {
+ if (!currentStateChangedAt.equals(currentRequestedAt) || hasAttempts) {
+ throw new IllegalArgumentException("requested receipt fields are inconsistent");
+ }
+ }
+ if (currentState == ArtifactDeletionState.METADATA_TOMBSTONED && hasAttempts) {
+ throw new IllegalArgumentException("tombstoned receipt fields are inconsistent");
+ }
+ if (currentState == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED) {
+ if (currentCompletedAt == null
+ || !currentCompletedAt.equals(currentStateChangedAt)
+ || currentFailureCode != null) {
+ throw new IllegalArgumentException("completed receipt fields are inconsistent");
+ }
+ return;
+ }
+ if (currentCompletedAt != null) {
+ throw new IllegalArgumentException("only completed receipts may have completedAt");
+ }
+ if (currentState == ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED) {
+ if (!hasAttempts
+ || !currentLastAttemptAt.equals(currentStateChangedAt)
+ || currentFailureCode == null) {
+ throw new IllegalArgumentException("failed receipt fields are inconsistent");
+ }
+ } else if (currentFailureCode != null) {
+ throw new IllegalArgumentException("only failed receipts may have failureCode");
+ }
+ }
+
+ private static String requireChecksum(String value) {
+ String normalized = requireText(value, "artifactChecksum");
+ if (!SHA_256_PATTERN.matcher(normalized).matches()) {
+ throw new IllegalArgumentException("artifactChecksum must be a lowercase SHA-256 digest");
+ }
+ return normalized;
+ }
+
+ private static String requireFailureCode(String value) {
+ String normalized = requireText(value, "controlledFailureCode");
+ if (!FAILURE_CODE_PATTERN.matcher(normalized).matches()) {
+ throw new IllegalArgumentException("failureCode must be a controlled code");
+ }
+ return normalized;
+ }
+
+ private static String requireText(String value, String fieldName) {
+ String normalized = normalizeOptional(value);
+ if (normalized == null) {
+ throw new IllegalArgumentException(fieldName + " must not be blank");
+ }
+ if (normalized.length() > MAX_IDENTIFIER_LENGTH) {
+ throw new IllegalArgumentException(fieldName + " exceeds the configured bound");
+ }
+ return normalized;
+ }
+
+ private static String normalizeOptional(String value) {
+ if (value == null) {
+ return null;
+ }
+ String normalized = value.strip();
+ return normalized.isEmpty() ? null : normalized;
+ }
+
+ private static IllegalStateException invalidTransition() {
+ return new IllegalStateException("artifact deletion receipt transition is invalid");
+ }
+}
diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java
new file mode 100644
index 00000000..bbf6fd4e
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java
@@ -0,0 +1,100 @@
+package com.clearfolio.viewer.lifecycle;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Versioned persistence boundary for tenant-bound artifact-deletion receipts.
+ *
+ * Standalone deployments may use the append-only reference ledger. Durable
+ * MSA adapters must preserve the same immutable request identity, monotonic
+ * state transitions, exact artifact digest, privacy-safe audit correlation,
+ * idempotency, and fail-closed conflict semantics.
+ */
+public interface ArtifactDeletionReceiptStore {
+
+ /**
+ * Durably accepts one idempotent artifact-deletion request.
+ *
+ * @param requestId deletion idempotency identifier
+ * @param tenantId tenant that owns the conversion job
+ * @param jobId permanently reserved conversion-job identifier
+ * @param artifactChecksum lowercase SHA-256 artifact digest
+ * @param auditCorrelationId privacy-safe audit correlation identifier
+ * @param requestedAt instant when the request became durable
+ * @return new or previously accepted identical receipt
+ */
+ ArtifactDeletionReceipt request(
+ UUID requestId,
+ String tenantId,
+ UUID jobId,
+ String artifactChecksum,
+ String auditCorrelationId,
+ Instant requestedAt
+ );
+
+ /**
+ * Records that tenant-owned job metadata has been tombstoned.
+ *
+ * @param jobId permanently reserved conversion-job identifier
+ * @param transitionedAt instant when the state became durable
+ * @return updated receipt
+ */
+ ArtifactDeletionReceipt markMetadataTombstoned(UUID jobId, Instant transitionedAt);
+
+ /**
+ * Marks exact-digest artifact cleanup ready for a worker attempt.
+ *
+ * @param jobId permanently reserved conversion-job identifier
+ * @param transitionedAt instant when the state became durable
+ * @return updated receipt
+ */
+ ArtifactDeletionReceipt markCleanupPending(UUID jobId, Instant transitionedAt);
+
+ /**
+ * Records one controlled cleanup failure while retaining retryable evidence.
+ *
+ * @param jobId permanently reserved conversion-job identifier
+ * @param failureCode controlled non-sensitive failure code
+ * @param attemptedAt failed attempt instant
+ * @return updated failed receipt
+ */
+ ArtifactDeletionReceipt recordCleanupFailure(
+ UUID jobId,
+ String failureCode,
+ Instant attemptedAt
+ );
+
+ /**
+ * Records successful terminal cleanup of the exact artifact digest.
+ *
+ * @param jobId permanently reserved conversion-job identifier
+ * @param completedAt completion instant
+ * @return updated terminal receipt
+ */
+ ArtifactDeletionReceipt markCleanupCompleted(UUID jobId, Instant completedAt);
+
+ /**
+ * Finds the latest receipt for a job identifier.
+ *
+ * @param jobId conversion-job identifier
+ * @return receipt when present
+ */
+ Optional findByJobId(UUID jobId);
+
+ /**
+ * Returns all incomplete receipts in deterministic request order.
+ *
+ * @return pending and failed receipts
+ */
+ List pendingReceipts();
+
+ /**
+ * Returns the number of incomplete receipts.
+ *
+ * @return pending and failed receipt count
+ */
+ int pendingCount();
+}
diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java
new file mode 100644
index 00000000..de7287ad
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java
@@ -0,0 +1,31 @@
+package com.clearfolio.viewer.lifecycle;
+
+/**
+ * Durable states in the artifact-deletion receipt lifecycle.
+ */
+public enum ArtifactDeletionState {
+ /**
+ * An authorized deletion request has been durably accepted.
+ */
+ DELETION_REQUESTED,
+
+ /**
+ * Tenant-owned job metadata has been tombstoned.
+ */
+ METADATA_TOMBSTONED,
+
+ /**
+ * Exact-generation artifact cleanup is ready for a worker attempt.
+ */
+ ARTIFACT_CLEANUP_PENDING,
+
+ /**
+ * Artifact cleanup completed and the receipt is terminal.
+ */
+ ARTIFACT_CLEANUP_COMPLETED,
+
+ /**
+ * Artifact cleanup failed and remains eligible for a controlled retry.
+ */
+ ARTIFACT_CLEANUP_FAILED
+}
diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java
index 74da3832..8b074888 100644
--- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java
+++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java
@@ -238,10 +238,17 @@ public List findLifecycleEventsByJobId(UUID jobId)
/**
* Returns lifecycle events for a tenant.
*
+ * Missing or blank scoped tenant identifiers fail closed and never infer
+ * the explicit legacy demo tenant.
+ *
* @param tenantId tenant identifier
- * @return append-only lifecycle events for the tenant
+ * @return append-only lifecycle events for the tenant, or an empty list when
+ * scoped tenant context is absent
*/
public List findLifecycleEventsByTenantId(String tenantId) {
+ if (tenantId == null || tenantId.isBlank()) {
+ return List.of();
+ }
String normalizedTenantId = normalizeTenantId(tenantId);
return lifecycleEvents.stream()
.filter(event -> event.tenantId().equals(normalizedTenantId))
@@ -379,7 +386,7 @@ private String contentKey(String tenantId, String contentHash) {
}
private String normalizeTenantId(String tenantId) {
- return tenantId == null || tenantId.isBlank() ? "buyer-demo" : tenantId.strip();
+ return Objects.requireNonNull(tenantId, "tenantId").strip();
}
private boolean matchesContentIndex(ConversionJob job, String expectedContentKey) {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a7fe3844..9826263b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -33,6 +33,11 @@ clearfolio:
# restarts. in-memory: volatile store for tests and ephemeral demos.
mode: filesystem
root-dir: data/artifacts
+ artifact-deletion-ledger:
+ # Versioned append-only receipt snapshots are forced to storage before a
+ # lifecycle transition is reported as durable. Standalone tests can use the
+ # no-argument in-memory adapter; production keeps restart-replay evidence.
+ path: data/artifact-deletion-receipts.log
viewer:
security:
diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java
new file mode 100644
index 00000000..32163d00
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java
@@ -0,0 +1,372 @@
+package com.clearfolio.viewer.lifecycle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Exercises fail-closed receipt validation and append-only replay branches.
+ */
+class ArtifactDeletionLedgerCoverageTest {
+
+ private static final UUID REQUEST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555");
+ private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ private static final String TENANT_ID = "tenant-north";
+ private static final String CHECKSUM = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+ private static final String AUDIT_ID = "audit-v1:0123456789abcdef0123456789abcdef";
+ private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T00:00:00Z");
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ void stringConfigurationSupportsNullBlankAndFileBackedModes() {
+ ArtifactDeletionLedger nullConfigured = new ArtifactDeletionLedger((String) null);
+ ArtifactDeletionLedger blankConfigured = new ArtifactDeletionLedger(" ");
+ Path configuredPath = tempDirectory.resolve("configured_receipts.log");
+ ArtifactDeletionLedger fileConfigured = new ArtifactDeletionLedger(configuredPath.toString());
+
+ request(nullConfigured, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ request(blankConfigured, UUID.randomUUID(), UUID.randomUUID(), TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ request(fileConfigured, UUID.randomUUID(), UUID.randomUUID(), TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+
+ assertFalse(nullConfigured.findByJobId(UUID.randomUUID()).isPresent());
+ assertEquals(1, blankConfigured.pendingCount());
+ assertTrue(Files.exists(configuredPath));
+ }
+
+ @Test
+ void pendingReceiptsAreSortedByRequestTimeAndJobIdentifier() {
+ ArtifactDeletionReceiptStore store = new ArtifactDeletionLedger();
+ UUID laterJob = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
+ UUID earlierJob = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ UUID sameTimeEarlierJob = UUID.fromString("00000000-0000-0000-0000-000000000000");
+ store.request(UUID.randomUUID(), TENANT_ID, laterJob, CHECKSUM, AUDIT_ID, REQUESTED_AT.plusSeconds(1));
+ store.request(UUID.randomUUID(), TENANT_ID, earlierJob, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ store.request(UUID.randomUUID(), TENANT_ID, sameTimeEarlierJob, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+
+ assertEquals(
+ List.of(sameTimeEarlierJob, earlierJob, laterJob),
+ store.pendingReceipts().stream().map(ArtifactDeletionReceipt::jobId).toList()
+ );
+ }
+
+ @Test
+ void immutableIdentityComparisonRejectsEveryChangedField() {
+ ArtifactDeletionReceipt baseline = receipt(
+ REQUEST_ID,
+ TENANT_ID,
+ JOB_ID,
+ CHECKSUM,
+ AUDIT_ID,
+ REQUESTED_AT,
+ REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ 0,
+ null,
+ null,
+ null
+ );
+
+ assertFalse(baseline.hasSameIdentity(null));
+ assertFalse(baseline.hasSameIdentity(receipt(UUID.randomUUID(), TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null)));
+ assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, "tenant-south", JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null)));
+ assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, UUID.randomUUID(), CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null)));
+ assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, JOB_ID,
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null)));
+ assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM,
+ "audit-v1:ffffffffffffffffffffffffffffffff", REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null)));
+ assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT.plusSeconds(1), REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null)));
+ }
+
+ @Test
+ void receiptConstructorRejectsInconsistentStateEvidence() {
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.minusSeconds(1),
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED, 0,
+ REQUESTED_AT.minusSeconds(1), null, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.METADATA_TOMBSTONED, 0,
+ null, REQUESTED_AT, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, 0,
+ null, null, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, 0,
+ null, REQUESTED_AT.plusSeconds(2), null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, 0,
+ null, REQUESTED_AT.plusSeconds(1), "storage_failed"
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, 1,
+ null, null, "storage_failed"
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(2),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, 1,
+ REQUESTED_AT.plusSeconds(1), null, "storage_failed"
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT.plusSeconds(1),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, 0,
+ REQUESTED_AT.plusSeconds(1), null, "storage_failed"
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.METADATA_TOMBSTONED, 0,
+ null, null, "storage_failed"
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, "t".repeat(257), JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, "a".repeat(257),
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null
+ ));
+ assertThrows(IllegalArgumentException.class, () -> receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM.toUpperCase(), AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null
+ ));
+ }
+
+ @Test
+ void transitionMethodsRejectWrongStateAndNullTime() {
+ ArtifactDeletionReceipt requested = receipt(
+ REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID,
+ REQUESTED_AT, REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null
+ );
+
+ assertThrows(NullPointerException.class, () -> requested.markMetadataTombstoned(null));
+ assertThrows(IllegalStateException.class, () -> requested.markCleanupPending(REQUESTED_AT));
+ assertThrows(IllegalStateException.class, () -> requested.recordCleanupFailure("storage_failed", REQUESTED_AT));
+ assertThrows(IllegalStateException.class, () -> requested.markCleanupCompleted(REQUESTED_AT));
+ assertEquals(REQUESTED_AT, requested.markMetadataTombstoned(REQUESTED_AT).stateChangedAt());
+ }
+
+ @Test
+ void strictUtf8CrLfAndBlankLineReplayAreHandledExplicitly() throws IOException {
+ Path source = tempDirectory.resolve("source.log");
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(source);
+ request(ledger, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ String sourceText = Files.readString(source, StandardCharsets.UTF_8);
+
+ Path crlf = tempDirectory.resolve("crlf.log");
+ Files.writeString(crlf, sourceText.replace("\n", "\r\n"), StandardCharsets.UTF_8);
+ assertTrue(new ArtifactDeletionLedger(crlf).findByJobId(JOB_ID).isPresent());
+
+ Path blank = tempDirectory.resolve("blank.log");
+ Files.writeString(blank, "\n", StandardCharsets.UTF_8);
+ assertEquals(
+ "artifact deletion ledger contains an invalid line",
+ assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(blank)).getMessage()
+ );
+
+ Path invalidUtf8 = tempDirectory.resolve("invalid_utf8.log");
+ Files.write(invalidUtf8, new byte[] {(byte) 0xC3, 0x28, '\n'});
+ assertEquals(
+ "artifact deletion ledger contains an invalid line",
+ assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(invalidUtf8)).getMessage()
+ );
+ }
+
+ @Test
+ void loadAndWriteIoFailuresAreReportedWithoutPartialSuccess() throws IOException {
+ Path directoryInsteadOfLedger = tempDirectory.resolve("ledger_directory");
+ Files.createDirectory(directoryInsteadOfLedger);
+ assertEquals(
+ "artifact deletion ledger cannot be loaded",
+ assertThrows(
+ IllegalStateException.class,
+ () -> new ArtifactDeletionLedger(directoryInsteadOfLedger)
+ ).getMessage()
+ );
+
+ Path parentFile = tempDirectory.resolve("parent_file");
+ Files.writeString(parentFile, "not a directory", StandardCharsets.UTF_8);
+ ArtifactDeletionLedger unwritable = new ArtifactDeletionLedger(parentFile.resolve("ledger.log"));
+ assertEquals(
+ "artifact deletion ledger cannot be written",
+ assertThrows(
+ IllegalStateException.class,
+ () -> request(unwritable, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT)
+ ).getMessage()
+ );
+ assertTrue(unwritable.findByJobId(JOB_ID).isEmpty());
+ }
+
+ @Test
+ void replayRejectsInvalidFirstRecordIdentityAndTransitionSequences() throws IOException {
+ Path valid = tempDirectory.resolve("valid_sequence.log");
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(valid);
+ request(ledger, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1));
+ ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2));
+ ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(3));
+ List lines = Files.readAllLines(valid, StandardCharsets.UTF_8);
+
+ Path nonRequestedFirst = tempDirectory.resolve("non_requested_first.log");
+ Files.write(nonRequestedFirst, lines.subList(1, 2), StandardCharsets.UTF_8);
+ assertInvalidLedger(nonRequestedFirst);
+
+ Path nonZeroFirstAttempt = tempDirectory.resolve("nonzero_first_attempt.log");
+ String[] requestedFields = fields(lines.getFirst());
+ requestedFields[9] = "1";
+ Files.writeString(nonZeroFirstAttempt, String.join("\t", requestedFields), StandardCharsets.UTF_8);
+ assertInvalidLedger(nonZeroFirstAttempt);
+
+ Path completedThenPending = tempDirectory.resolve("completed_then_pending.log");
+ List invalidTransitionLines = new ArrayList<>(lines);
+ invalidTransitionLines.add(lines.get(2));
+ Files.write(completedThenPending, invalidTransitionLines, StandardCharsets.UTF_8);
+ assertInvalidLedger(completedThenPending);
+
+ Path nonMonotonic = tempDirectory.resolve("non_monotonic.log");
+ List nonMonotonicLines = new ArrayList<>(lines.subList(0, 3));
+ String[] pendingFields = fields(nonMonotonicLines.get(2));
+ pendingFields[7] = REQUESTED_AT.toString();
+ nonMonotonicLines.set(2, String.join("\t", pendingFields));
+ Files.write(nonMonotonic, nonMonotonicLines, StandardCharsets.UTF_8);
+ assertInvalidLedger(nonMonotonic);
+ }
+
+ @Test
+ void replayRejectsIdentityConflictInvalidBase64AndMalformedRequiredFields() throws IOException {
+ Path north = tempDirectory.resolve("north.log");
+ request(new ArtifactDeletionLedger(north), JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ Path south = tempDirectory.resolve("south.log");
+ request(
+ new ArtifactDeletionLedger(south),
+ JOB_ID,
+ UUID.randomUUID(),
+ "tenant-south",
+ CHECKSUM,
+ "audit-v1:ffffffffffffffffffffffffffffffff",
+ REQUESTED_AT.plusSeconds(1)
+ );
+
+ Path conflict = tempDirectory.resolve("identity_conflict.log");
+ List conflictLines = new ArrayList<>(Files.readAllLines(north, StandardCharsets.UTF_8));
+ conflictLines.addAll(Files.readAllLines(south, StandardCharsets.UTF_8));
+ Files.write(conflict, conflictLines, StandardCharsets.UTF_8);
+ assertInvalidLedger(conflict);
+
+ String[] invalidBase64Fields = fields(Files.readAllLines(north, StandardCharsets.UTF_8).getFirst());
+ invalidBase64Fields[2] = "*";
+ Path invalidBase64 = tempDirectory.resolve("invalid_base64.log");
+ Files.writeString(invalidBase64, String.join("\t", invalidBase64Fields), StandardCharsets.UTF_8);
+ assertInvalidLedger(invalidBase64);
+
+ String[] absentTenantFields = fields(Files.readAllLines(north, StandardCharsets.UTF_8).getFirst());
+ absentTenantFields[2] = "-";
+ Path absentTenant = tempDirectory.resolve("absent_tenant.log");
+ Files.writeString(absentTenant, String.join("\t", absentTenantFields), StandardCharsets.UTF_8);
+ assertInvalidLedger(absentTenant);
+ }
+
+ private static void assertInvalidLedger(Path path) {
+ assertEquals(
+ "artifact deletion ledger contains an invalid line",
+ assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(path)).getMessage()
+ );
+ }
+
+ private static String[] fields(String line) {
+ return line.split("\t", -1);
+ }
+
+ private static ArtifactDeletionReceipt request(
+ ArtifactDeletionReceiptStore store,
+ UUID jobId,
+ UUID requestId,
+ String tenantId,
+ String checksum,
+ String auditId,
+ Instant requestedAt
+ ) {
+ return store.request(requestId, tenantId, jobId, checksum, auditId, requestedAt);
+ }
+
+ private static ArtifactDeletionReceipt receipt(
+ UUID requestId,
+ String tenantId,
+ UUID jobId,
+ String checksum,
+ String auditId,
+ Instant requestedAt,
+ Instant stateChangedAt,
+ ArtifactDeletionState state,
+ int attemptCount,
+ Instant lastAttemptAt,
+ Instant completedAt,
+ String failureCode
+ ) {
+ return new ArtifactDeletionReceipt(
+ requestId,
+ tenantId,
+ jobId,
+ checksum,
+ auditId,
+ requestedAt,
+ stateChangedAt,
+ state,
+ attemptCount,
+ lastAttemptAt,
+ completedAt,
+ failureCode
+ );
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCrashTailTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCrashTailTest.java
new file mode 100644
index 00000000..0dc349b7
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCrashTailTest.java
@@ -0,0 +1,75 @@
+package com.clearfolio.viewer.lifecycle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Verifies that restart replay never promotes an uncommitted ledger tail.
+ */
+class ArtifactDeletionLedgerCrashTailTest {
+
+ private static final UUID REQUEST_ID = UUID.fromString("12121212-3434-5656-7878-909090909090");
+ private static final UUID JOB_ID = UUID.fromString("abababab-cdcd-efef-1212-343434343434");
+ private static final String CHECKSUM = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
+ private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T09:00:00Z");
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ void unterminatedFinalRecordFailsClosedAfterRestart() throws IOException {
+ Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log");
+ ArtifactDeletionLedger firstProcess = new ArtifactDeletionLedger(ledgerPath);
+ firstProcess.request(
+ REQUEST_ID,
+ "tenant-crash-recovery",
+ JOB_ID,
+ CHECKSUM,
+ "audit-v1:crash-tail-regression",
+ REQUESTED_AT
+ );
+
+ byte[] durableBytes = Files.readAllBytes(ledgerPath);
+ byte[] lineTerminator = {'\n'};
+ assertTrue(endsWith(durableBytes, lineTerminator), "ledger append did not emit its commit delimiter");
+ assertTrue(
+ durableBytes.length < 2 || durableBytes[durableBytes.length - 2] != '\r',
+ "ledger format must use host-independent LF rather than CRLF"
+ );
+ Files.write(
+ ledgerPath,
+ Arrays.copyOf(durableBytes, durableBytes.length - lineTerminator.length)
+ );
+
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> new ArtifactDeletionLedger(ledgerPath)
+ );
+
+ assertEquals("artifact deletion ledger contains an invalid line", exception.getMessage());
+ }
+
+ private static boolean endsWith(byte[] value, byte[] suffix) {
+ if (value.length < suffix.length) {
+ return false;
+ }
+ int offset = value.length - suffix.length;
+ for (int index = 0; index < suffix.length; index++) {
+ if (value[offset + index] != suffix[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java
new file mode 100644
index 00000000..13a8e074
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java
@@ -0,0 +1,158 @@
+package com.clearfolio.viewer.lifecycle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.UnaryOperator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Covers receipt replay branches that protect immutable identity and monotonic
+ * retry evidence.
+ */
+class ArtifactDeletionLedgerReplayCoverageTest {
+
+ private static final UUID REQUEST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555");
+ private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ private static final String TENANT_ID = "tenant-north";
+ private static final String CHECKSUM = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+ private static final String AUDIT_ID = "audit-v1:0123456789abcdef0123456789abcdef";
+ private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T00:00:00Z");
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ void replayAcceptsCompleteFailureRetrySequenceAndRejectsWorkAfterCompletion() throws Exception {
+ Path path = tempDirectory.resolve("retry_sequence.log");
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(path);
+ ledger.request(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1));
+ ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2));
+ ledger.recordCleanupFailure(JOB_ID, "artifact_store_delete_failed", REQUESTED_AT.plusSeconds(3));
+ ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(4));
+ ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(5));
+
+ ArtifactDeletionReceipt replayed = new ArtifactDeletionLedger(path)
+ .findByJobId(JOB_ID)
+ .orElseThrow();
+ assertTrue(replayed.isCompleted());
+ assertEquals(1, replayed.attemptCount());
+ assertEquals(REQUESTED_AT.plusSeconds(3), replayed.lastAttemptAt());
+
+ List lines = new ArrayList<>(Files.readAllLines(path, StandardCharsets.UTF_8));
+ lines.add(lines.get(2));
+ Files.write(path, lines, StandardCharsets.UTF_8);
+
+ assertInvalidLedger(path);
+ }
+
+ @Test
+ void replayRejectsWrongRecordVersionAndBlankRequiredTenant() throws Exception {
+ Path source = tempDirectory.resolve("source.log");
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(source);
+ ledger.request(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ String[] validFields = Files.readString(source, StandardCharsets.UTF_8)
+ .stripTrailing()
+ .split("\\t", -1);
+
+ String[] wrongVersionFields = validFields.clone();
+ wrongVersionFields[0] = "RECEIPT_V2";
+ Path wrongVersion = tempDirectory.resolve("wrong_version.log");
+ Files.writeString(
+ wrongVersion,
+ String.join("\t", wrongVersionFields) + "\n",
+ StandardCharsets.UTF_8
+ );
+ assertInvalidLedger(wrongVersion);
+
+ String[] blankTenantFields = validFields.clone();
+ blankTenantFields[2] = "";
+ Path blankTenant = tempDirectory.resolve("blank_tenant.log");
+ Files.writeString(
+ blankTenant,
+ String.join("\t", blankTenantFields) + "\n",
+ StandardCharsets.UTF_8
+ );
+ assertInvalidLedger(blankTenant);
+ }
+
+ @Test
+ void failedReceiptRejectsLastAttemptTimeThatDiffersFromStateTime() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> new ArtifactDeletionReceipt(
+ REQUEST_ID,
+ TENANT_ID,
+ JOB_ID,
+ CHECKSUM,
+ AUDIT_ID,
+ REQUESTED_AT,
+ REQUESTED_AT.plusSeconds(3),
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED,
+ 1,
+ REQUESTED_AT.plusSeconds(2),
+ null,
+ "artifact_store_delete_failed"
+ )
+ );
+
+ assertEquals("Failed receipt has inconsistent failure evidence.", exception.getMessage());
+ }
+
+ @Test
+ void transitionRejectsAChangedImmutableIdentity() throws Exception {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ ledger.request(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT);
+ Method transition = ArtifactDeletionLedger.class.getDeclaredMethod(
+ "transition",
+ UUID.class,
+ UnaryOperator.class
+ );
+ transition.setAccessible(true);
+ UnaryOperator changeTenant = current -> new ArtifactDeletionReceipt(
+ current.requestId(),
+ "tenant-south",
+ current.jobId(),
+ current.artifactChecksum(),
+ current.auditCorrelationId(),
+ current.requestedAt(),
+ current.stateChangedAt(),
+ current.state(),
+ current.attemptCount(),
+ current.lastAttemptAt(),
+ current.completedAt(),
+ current.failureCode()
+ );
+
+ InvocationTargetException invocation = assertThrows(
+ InvocationTargetException.class,
+ () -> transition.invoke(ledger, JOB_ID, changeTenant)
+ );
+
+ assertEquals(
+ "Artifact deletion receipt transition changed immutable identity.",
+ invocation.getCause().getMessage()
+ );
+ assertEquals(TENANT_ID, ledger.findByJobId(JOB_ID).orElseThrow().tenantId());
+ }
+
+ private static void assertInvalidLedger(Path path) {
+ assertEquals(
+ "artifact deletion ledger contains an invalid line",
+ assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(path)).getMessage()
+ );
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java
new file mode 100644
index 00000000..af23162a
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java
@@ -0,0 +1,381 @@
+package com.clearfolio.viewer.lifecycle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Verifies that restart replay accepts only complete, internally consistent
+ * deletion-lifecycle evidence.
+ */
+class ArtifactDeletionLedgerReplayValidationTest {
+
+ private static final UUID REQUEST_ID = UUID.fromString("23232323-4545-6767-8989-010101010101");
+ private static final UUID JOB_ID = UUID.fromString("bcbcbcbc-dede-fafa-2323-454545454545");
+ private static final String TENANT_ID = "tenant-replay-validation";
+ private static final String CHECKSUM = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
+ private static final String AUDIT_ID = "audit-v1:replay-validation";
+ private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T10:00:00Z");
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ void invalidUtf8LedgerBytesFailClosed() throws IOException {
+ Path ledgerPath = ledgerPath();
+ Files.write(ledgerPath, new byte[] {(byte) 0xc3, 0x28, '\n'});
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void whitespaceOnlyRequiredFieldFailsClosed() throws IOException {
+ Path ledgerPath = ledgerPath();
+ Files.writeString(
+ ledgerPath,
+ line(
+ " ",
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 0,
+ null,
+ null,
+ null
+ ),
+ StandardCharsets.UTF_8
+ );
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void immutableIdentityConflictFailsClosed() throws IOException {
+ Path ledgerPath = ledgerPath();
+ String requested = line(
+ TENANT_ID,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 0,
+ null,
+ null,
+ null
+ );
+ String conflicting = line(
+ "tenant-conflicting-owner",
+ ArtifactDeletionState.METADATA_TOMBSTONED,
+ REQUESTED_AT.plusSeconds(1),
+ 0,
+ null,
+ null,
+ null
+ );
+ Files.writeString(ledgerPath, requested + conflicting, StandardCharsets.UTF_8);
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void nonMonotonicTransitionTimeFailsClosed() throws IOException {
+ Path ledgerPath = ledgerPath();
+ String requested = line(
+ TENANT_ID,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 0,
+ null,
+ null,
+ null
+ );
+ String tombstoned = line(
+ TENANT_ID,
+ ArtifactDeletionState.METADATA_TOMBSTONED,
+ REQUESTED_AT.plusSeconds(3),
+ 0,
+ null,
+ null,
+ null
+ );
+ String pendingEarlier = line(
+ TENANT_ID,
+ ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING,
+ REQUESTED_AT.plusSeconds(2),
+ 0,
+ null,
+ null,
+ null
+ );
+ Files.writeString(
+ ledgerPath,
+ requested + tombstoned + pendingEarlier,
+ StandardCharsets.UTF_8
+ );
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void illegalSubsequentStateFailsClosed() throws IOException {
+ Path ledgerPath = ledgerPath();
+ String requested = line(
+ TENANT_ID,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 0,
+ null,
+ null,
+ null
+ );
+ String pendingWithoutTombstone = line(
+ TENANT_ID,
+ ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING,
+ REQUESTED_AT.plusSeconds(1),
+ 0,
+ null,
+ null,
+ null
+ );
+ Files.writeString(
+ ledgerPath,
+ requested + pendingWithoutTombstone,
+ StandardCharsets.UTF_8
+ );
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void stateSnapshotsRejectImpossibleAttemptEvidence() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 1,
+ REQUESTED_AT,
+ null,
+ null
+ )
+ );
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(
+ ArtifactDeletionState.METADATA_TOMBSTONED,
+ REQUESTED_AT.plusSeconds(1),
+ 1,
+ REQUESTED_AT,
+ null,
+ null
+ )
+ );
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(
+ ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING,
+ REQUESTED_AT.plusSeconds(2),
+ 1,
+ REQUESTED_AT.plusSeconds(3),
+ null,
+ null
+ )
+ );
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED,
+ REQUESTED_AT.plusSeconds(2),
+ 0,
+ null,
+ null,
+ "storage_timeout"
+ )
+ );
+ }
+
+ @Test
+ void requestedStateCannotPairZeroAttemptsWithAttemptTime() throws IOException {
+ Path ledgerPath = ledgerPath();
+ Files.writeString(
+ ledgerPath,
+ line(
+ TENANT_ID,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 0,
+ REQUESTED_AT,
+ null,
+ null
+ ),
+ StandardCharsets.UTF_8
+ );
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void retriedPendingStateMustPreserveLastAttemptEvidence() throws IOException {
+ Path ledgerPath = ledgerPath();
+ Instant tombstonedAt = REQUESTED_AT.plusSeconds(1);
+ Instant firstPendingAt = REQUESTED_AT.plusSeconds(2);
+ Instant failedAt = REQUESTED_AT.plusSeconds(3);
+ Instant retryAt = REQUESTED_AT.plusSeconds(4);
+ String requested = line(
+ TENANT_ID,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ REQUESTED_AT,
+ 0,
+ null,
+ null,
+ null
+ );
+ String tombstoned = line(
+ TENANT_ID,
+ ArtifactDeletionState.METADATA_TOMBSTONED,
+ tombstonedAt,
+ 0,
+ null,
+ null,
+ null
+ );
+ String pending = line(
+ TENANT_ID,
+ ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING,
+ firstPendingAt,
+ 0,
+ null,
+ null,
+ null
+ );
+ String failed = line(
+ TENANT_ID,
+ ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED,
+ failedAt,
+ 1,
+ failedAt,
+ null,
+ "storage_timeout"
+ );
+ String retryWithRewrittenAttemptTime = line(
+ TENANT_ID,
+ ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING,
+ retryAt,
+ 1,
+ firstPendingAt,
+ null,
+ null
+ );
+ Files.writeString(
+ ledgerPath,
+ requested + tombstoned + pending + failed + retryWithRewrittenAttemptTime,
+ StandardCharsets.UTF_8
+ );
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ @Test
+ void retriedPendingStateMustPreserveAttemptCount() throws IOException {
+ Path ledgerPath = ledgerPath();
+ Instant tombstonedAt = REQUESTED_AT.plusSeconds(1);
+ Instant pendingAt = REQUESTED_AT.plusSeconds(2);
+ Instant failedAt = REQUESTED_AT.plusSeconds(3);
+ Instant retryAt = REQUESTED_AT.plusSeconds(4);
+ Files.writeString(
+ ledgerPath,
+ line(TENANT_ID, ArtifactDeletionState.DELETION_REQUESTED, REQUESTED_AT, 0, null, null, null)
+ + line(TENANT_ID, ArtifactDeletionState.METADATA_TOMBSTONED, tombstonedAt,
+ 0, null, null, null)
+ + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, pendingAt,
+ 0, null, null, null)
+ + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, failedAt,
+ 1, failedAt, null, "storage_timeout")
+ + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, retryAt,
+ 2, failedAt, null, null),
+ StandardCharsets.UTF_8
+ );
+
+ assertInvalidLedger(ledgerPath);
+ }
+
+ private Path ledgerPath() {
+ return tempDirectory.resolve("artifact_deletion_receipt.log");
+ }
+
+ private static void assertInvalidLedger(Path ledgerPath) {
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> new ArtifactDeletionLedger(ledgerPath)
+ );
+ assertEquals("artifact deletion ledger contains an invalid line", exception.getMessage());
+ }
+
+ private static ArtifactDeletionReceipt receipt(
+ ArtifactDeletionState state,
+ Instant stateChangedAt,
+ int attemptCount,
+ Instant lastAttemptAt,
+ Instant completedAt,
+ String failureCode
+ ) {
+ return new ArtifactDeletionReceipt(
+ REQUEST_ID,
+ TENANT_ID,
+ JOB_ID,
+ CHECKSUM,
+ AUDIT_ID,
+ REQUESTED_AT,
+ stateChangedAt,
+ state,
+ attemptCount,
+ lastAttemptAt,
+ completedAt,
+ failureCode
+ );
+ }
+
+ private static String line(
+ String tenantId,
+ ArtifactDeletionState state,
+ Instant stateChangedAt,
+ int attemptCount,
+ Instant lastAttemptAt,
+ Instant completedAt,
+ String failureCode
+ ) {
+ return String.join(
+ "\t",
+ "RECEIPT_V1",
+ REQUEST_ID.toString(),
+ encode(tenantId),
+ JOB_ID.toString(),
+ encode(CHECKSUM),
+ encode(AUDIT_ID),
+ REQUESTED_AT.toString(),
+ stateChangedAt.toString(),
+ state.name(),
+ Integer.toString(attemptCount),
+ optionalInstant(lastAttemptAt),
+ optionalInstant(completedAt),
+ failureCode == null ? "-" : encode(failureCode)
+ ) + "\n";
+ }
+
+ private static String encode(String value) {
+ return Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String optionalInstant(Instant value) {
+ return value == null ? "-" : value.toString();
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerTest.java
new file mode 100644
index 00000000..e4e77082
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerTest.java
@@ -0,0 +1,317 @@
+package com.clearfolio.viewer.lifecycle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Verifies durable, idempotent, monotonic artifact-deletion receipt evidence.
+ */
+class ArtifactDeletionLedgerTest {
+
+ private static final UUID REQUEST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555");
+ private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ private static final String TENANT_ID = "tenant-north";
+ private static final String ARTIFACT_CHECKSUM = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+ private static final String AUDIT_CORRELATION_ID = "audit-v1:0123456789abcdef0123456789abcdef";
+ private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T00:00:00Z");
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ void createsAndFindsRequestedReceipt() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+
+ ArtifactDeletionReceipt receipt = request(ledger);
+
+ assertEquals(REQUEST_ID, receipt.requestId());
+ assertEquals(TENANT_ID, receipt.tenantId());
+ assertEquals(JOB_ID, receipt.jobId());
+ assertEquals(ARTIFACT_CHECKSUM, receipt.artifactChecksum());
+ assertEquals(AUDIT_CORRELATION_ID, receipt.auditCorrelationId());
+ assertEquals(REQUESTED_AT, receipt.requestedAt());
+ assertEquals(REQUESTED_AT, receipt.stateChangedAt());
+ assertEquals(ArtifactDeletionState.DELETION_REQUESTED, receipt.state());
+ assertEquals(0, receipt.attemptCount());
+ assertTrue(ledger.findByJobId(JOB_ID).isPresent());
+ assertEquals(1, ledger.pendingCount());
+ }
+
+ @Test
+ void identicalDeletionRequestIsIdempotent() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ ArtifactDeletionReceipt original = request(ledger);
+
+ ArtifactDeletionReceipt repeated = request(ledger);
+
+ assertSame(original, repeated);
+ assertEquals(1, ledger.pendingCount());
+ }
+
+ @Test
+ void conflictingRequestForReservedJobFailsClosed() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ request(ledger);
+
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> ledger.request(
+ UUID.randomUUID(),
+ "tenant-south",
+ JOB_ID,
+ ARTIFACT_CHECKSUM,
+ "audit-v1:ffffffffffffffffffffffffffffffff",
+ REQUESTED_AT.plusSeconds(1)
+ )
+ );
+
+ assertEquals("artifact deletion receipt conflicts with an existing lifecycle", exception.getMessage());
+ assertEquals(TENANT_ID, ledger.findByJobId(JOB_ID).orElseThrow().tenantId());
+ }
+
+ @Test
+ void stateTransitionsAreMonotonicAndCompletionIsTerminal() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ request(ledger);
+
+ ArtifactDeletionReceipt tombstoned = ledger.markMetadataTombstoned(
+ JOB_ID,
+ REQUESTED_AT.plusSeconds(1)
+ );
+ ArtifactDeletionReceipt pending = ledger.markCleanupPending(
+ JOB_ID,
+ REQUESTED_AT.plusSeconds(2)
+ );
+ ArtifactDeletionReceipt completed = ledger.markCleanupCompleted(
+ JOB_ID,
+ REQUESTED_AT.plusSeconds(3)
+ );
+
+ assertEquals(ArtifactDeletionState.METADATA_TOMBSTONED, tombstoned.state());
+ assertEquals(REQUESTED_AT.plusSeconds(1), tombstoned.stateChangedAt());
+ assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, pending.state());
+ assertEquals(REQUESTED_AT.plusSeconds(2), pending.stateChangedAt());
+ assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, completed.state());
+ assertEquals(REQUESTED_AT.plusSeconds(3), completed.stateChangedAt());
+ assertEquals(REQUESTED_AT.plusSeconds(3), completed.completedAt());
+ assertEquals(0, ledger.pendingCount());
+ assertThrows(
+ IllegalStateException.class,
+ () -> ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(4))
+ );
+ }
+
+ @Test
+ void transitionTimeCannotMoveBackward() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ request(ledger);
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.minusSeconds(1))
+ );
+
+ assertEquals("stateChangedAt must not precede the prior transition", exception.getMessage());
+ assertEquals(ArtifactDeletionState.DELETION_REQUESTED, ledger.findByJobId(JOB_ID).orElseThrow().state());
+ }
+
+ @Test
+ void failedCleanupCanBeRetriedAndAttemptsRemainDurable() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ request(ledger);
+ ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1));
+ ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2));
+
+ ArtifactDeletionReceipt firstFailure = ledger.recordCleanupFailure(
+ JOB_ID,
+ "storage_unavailable",
+ REQUESTED_AT.plusSeconds(3)
+ );
+ ArtifactDeletionReceipt retry = ledger.markCleanupPending(
+ JOB_ID,
+ REQUESTED_AT.plusSeconds(4)
+ );
+ ArtifactDeletionReceipt secondFailure = ledger.recordCleanupFailure(
+ JOB_ID,
+ "storage_unavailable",
+ REQUESTED_AT.plusSeconds(5)
+ );
+
+ assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, firstFailure.state());
+ assertEquals(1, firstFailure.attemptCount());
+ assertEquals("storage_unavailable", firstFailure.failureCode());
+ assertEquals(REQUESTED_AT.plusSeconds(3), firstFailure.stateChangedAt());
+ assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, retry.state());
+ assertEquals(1, retry.attemptCount());
+ assertEquals(REQUESTED_AT.plusSeconds(4), retry.stateChangedAt());
+ assertEquals(2, secondFailure.attemptCount());
+ assertEquals(REQUESTED_AT.plusSeconds(5), secondFailure.lastAttemptAt());
+ assertEquals(REQUESTED_AT.plusSeconds(5), secondFailure.stateChangedAt());
+ }
+
+ @Test
+ void failureCodeMustBeControlledAndCannotCarryStorageDetails() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+ request(ledger);
+ ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1));
+ ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2));
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> ledger.recordCleanupFailure(
+ JOB_ID,
+ "permission denied for /private/artifacts/" + JOB_ID + ".pdf",
+ REQUESTED_AT.plusSeconds(3)
+ )
+ );
+
+ assertEquals("failureCode must be a controlled code", exception.getMessage());
+ assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, ledger.findByJobId(JOB_ID).orElseThrow().state());
+ }
+
+ @Test
+ void fileBackedLedgerReplaysTheLatestReceiptAfterRestart() {
+ Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log");
+ ArtifactDeletionLedger firstProcess = new ArtifactDeletionLedger(ledgerPath);
+ request(firstProcess);
+ firstProcess.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1));
+ firstProcess.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2));
+ firstProcess.recordCleanupFailure(JOB_ID, "storage_timeout", REQUESTED_AT.plusSeconds(3));
+
+ ArtifactDeletionLedger restartedProcess = new ArtifactDeletionLedger(ledgerPath);
+ ArtifactDeletionReceipt restored = restartedProcess.findByJobId(JOB_ID).orElseThrow();
+
+ assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, restored.state());
+ assertEquals(1, restored.attemptCount());
+ assertEquals("storage_timeout", restored.failureCode());
+ assertEquals(REQUESTED_AT.plusSeconds(3), restored.stateChangedAt());
+ assertEquals(AUDIT_CORRELATION_ID, restored.auditCorrelationId());
+ assertEquals(List.of(restored), restartedProcess.pendingReceipts());
+ assertSame(restored, request(restartedProcess));
+ }
+
+ @Test
+ void completedReceiptsAreNotReturnedAsPendingAfterRestart() {
+ Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log");
+ ArtifactDeletionLedger firstProcess = new ArtifactDeletionLedger(ledgerPath);
+ request(firstProcess);
+ firstProcess.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1));
+ firstProcess.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2));
+ firstProcess.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(3));
+
+ ArtifactDeletionLedger restartedProcess = new ArtifactDeletionLedger(ledgerPath);
+
+ assertTrue(restartedProcess.pendingReceipts().isEmpty());
+ assertEquals(0, restartedProcess.pendingCount());
+ assertTrue(restartedProcess.findByJobId(JOB_ID).orElseThrow().isCompleted());
+ }
+
+ @Test
+ void malformedLedgerInputFailsClosed() throws IOException {
+ Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log");
+ Files.writeString(ledgerPath, "not-a-valid-ledger-line\n", StandardCharsets.UTF_8);
+
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> new ArtifactDeletionLedger(ledgerPath)
+ );
+
+ assertEquals("artifact deletion ledger contains an invalid line", exception.getMessage());
+ }
+
+ @Test
+ void oversizedLedgerLineFailsClosed() throws IOException {
+ Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log");
+ Files.writeString(ledgerPath, "x".repeat(ArtifactDeletionLedger.MAX_LEDGER_LINE_BYTES + 1), StandardCharsets.UTF_8);
+
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> new ArtifactDeletionLedger(ledgerPath)
+ );
+
+ assertEquals("artifact deletion ledger line exceeds the configured bound", exception.getMessage());
+ }
+
+ @Test
+ void missingReceiptAndInvalidTransitionFailClosed() {
+ ArtifactDeletionLedger ledger = new ArtifactDeletionLedger();
+
+ assertTrue(ledger.findByJobId(null).isEmpty());
+ assertThrows(
+ IllegalStateException.class,
+ () -> ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT)
+ );
+
+ request(ledger);
+ assertThrows(
+ IllegalStateException.class,
+ () -> ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(1))
+ );
+ }
+
+ @Test
+ void receiptValidationRejectsInvalidIdentityChecksumAndAttempts() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(" ", ARTIFACT_CHECKSUM, AUDIT_CORRELATION_ID, 0)
+ );
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(TENANT_ID, "not-a-sha256", AUDIT_CORRELATION_ID, 0)
+ );
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(TENANT_ID, ARTIFACT_CHECKSUM, " ", 0)
+ );
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> receipt(TENANT_ID, ARTIFACT_CHECKSUM, AUDIT_CORRELATION_ID, -1)
+ );
+ }
+
+ private static ArtifactDeletionReceipt receipt(
+ String tenantId,
+ String artifactChecksum,
+ String auditCorrelationId,
+ int attemptCount
+ ) {
+ return new ArtifactDeletionReceipt(
+ REQUEST_ID,
+ tenantId,
+ JOB_ID,
+ artifactChecksum,
+ auditCorrelationId,
+ REQUESTED_AT,
+ REQUESTED_AT,
+ ArtifactDeletionState.DELETION_REQUESTED,
+ attemptCount,
+ null,
+ null,
+ null
+ );
+ }
+
+ private static ArtifactDeletionReceipt request(ArtifactDeletionLedger ledger) {
+ return ledger.request(
+ REQUEST_ID,
+ TENANT_ID,
+ JOB_ID,
+ ARTIFACT_CHECKSUM,
+ AUDIT_CORRELATION_ID,
+ REQUESTED_AT
+ );
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantLifecycleIsolationTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantLifecycleIsolationTest.java
new file mode 100644
index 00000000..7a0c9828
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantLifecycleIsolationTest.java
@@ -0,0 +1,37 @@
+package com.clearfolio.viewer.repository;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import com.clearfolio.viewer.model.ConversionJob;
+
+/**
+ * Verifies that lifecycle-event queries never infer the demo tenant from a
+ * missing scoped tenant identifier.
+ */
+class InMemoryConversionJobRepositoryTenantLifecycleIsolationTest {
+
+ @Test
+ void nullAndBlankTenantQueriesCannotReadDemoLifecycleEvents() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionJob demoJob = new ConversionJob(
+ UUID.fromString("11111111-2222-3333-4444-555555555555"),
+ "buyer-demo",
+ "subject-demo",
+ "report.pdf",
+ "application/pdf",
+ "tenant-lifecycle-isolation-hash",
+ 42L,
+ 3
+ );
+ repository.findOrStoreByContentHash(demoJob);
+
+ assertEquals(1, repository.findLifecycleEventsByTenantId("buyer-demo").size());
+ assertTrue(repository.findLifecycleEventsByTenantId(null).isEmpty());
+ assertTrue(repository.findLifecycleEventsByTenantId(" ").isEmpty());
+ }
+}