From 5261356ac34e6545bce947ba0bcf2b1ce9f9be67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 12:59:56 +0900 Subject: [PATCH 001/178] security: rebuild audit pseudonymization on current main --- CHANGELOG.md | 6 +- docs/diagrams/submit-flow.md | 4 +- docs/diagrams/submit-policy-adapter-flow.md | 4 +- ...lio-viewer-unified-document-preview-prd.md | 6 +- .../2026-08-04-audit-pseudonymization.md | 108 +++++++++++ .../viewer/config/ConversionProperties.java | 40 ++++ .../security/AuditKeySeparationGuard.java | 49 +++++ .../viewer/security/AuditPseudonymizer.java | 140 ++++++++++++++ .../DefaultDocumentValidationService.java | 13 +- .../viewer/service/PolicyOverrideRequest.java | 12 +- src/main/resources/application.yml | 9 +- .../security/AuditKeySeparationGuardTest.java | 40 ++++ .../AuditPseudonymizerKeyStrengthTest.java | 41 ++++ .../security/AuditPseudonymizerTest.java | 182 ++++++++++++++++++ ...ultDocumentValidationServiceAuditTest.java | 170 ++++++++++++++++ .../service/PolicyOverrideRequestTest.java | 29 ++- 16 files changed, 831 insertions(+), 22 deletions(-) create mode 100644 docs/security/2026-08-04-audit-pseudonymization.md create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 373a661a2..cc5ba27b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Changed - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +### Security +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. +- 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. + # Changelog ## [Unreleased] @@ -50,4 +54,4 @@ - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. ### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file diff --git a/docs/diagrams/submit-flow.md b/docs/diagrams/submit-flow.md index 7b51e3cd4..bccde0f34 100644 --- a/docs/diagrams/submit-flow.md +++ b/docs/diagrams/submit-flow.md @@ -64,7 +64,7 @@ sequenceDiagram V->>P: getBlockedExtensions() alt Override headers valid V-->>V: validate override=true + token + approver - V-->>V: emit audit-safe log(extension, approver, tokenFingerprint) + V-->>V: emit audit-safe log(extension, approverFingerprint, tokenFingerprint) V-->>Svc: validation ok else Override missing/invalid V-->>Svc: UnsupportedDocumentFormatException or IllegalArgumentException @@ -94,6 +94,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym. The raw approver identifier is accepted only as validation input and is never emitted by the audit-safe log. + ## Exception paths covered - Missing or empty file diff --git a/docs/diagrams/submit-policy-adapter-flow.md b/docs/diagrams/submit-policy-adapter-flow.md index bc65dfe76..18cca5617 100644 --- a/docs/diagrams/submit-policy-adapter-flow.md +++ b/docs/diagrams/submit-policy-adapter-flow.md @@ -29,7 +29,7 @@ sequenceDiagram EH-->>C: 400 UNSUPPORTED_FORMAT else extension blocked and override=true alt token/approver valid - Val-->>Val: audit-safe log(extension, approverId, tokenFingerprint) + Val-->>Val: audit-safe log(extension, approverFingerprint, tokenFingerprint) Val-->>Svc: validation ok Svc->>Repo: findOrStoreByContentHash(job) Svc->>W: enqueue(jobId) when created @@ -60,6 +60,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym; the raw approver identifier is never written to the audit-safe log. + ## Deterministic adapter baseline - `pdf -> PDF_JS` diff --git a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md index d9fe8f4d5..b4b8bc760 100644 --- a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md +++ b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md @@ -1,7 +1,7 @@ # PRD: Clearfolio Viewer Unified Document Preview (Internal) Date: 2026-02-23 -Last updated: 2026-02-23 +Last updated: 2026-08-05 Owner: Product Manager Sources: `docs/architecture.md`, `docs/trd-integrated-document-viewer-platform.md`, `docs/prd-integrated-document-viewer-platform.md`, `docs/engineering/acceptance-criteria.md`, `docs/workflow/one-day-delivery-plan.md`, `docs/diagrams/*`, `AGENTS.md` @@ -196,7 +196,7 @@ Minimum claims/scopes (MVP intent): - preview session creation - viewer access (success/fail) - blocked-format attempts - - exception lane approvals (including approver id, token fingerprint, and rationale id if available) + - exception lane approvals (including `approverFingerprint`, token fingerprint, and rationale id if available); the raw approver identifier is never logged - operator-triggered retries ### 10.4 Browser security headers / CSP @@ -306,4 +306,4 @@ Minimum one-day deliverables: - Risk: Office formats (`docx`/`pptx`/`xlsx`) preview quality depends on converter availability; failures could impact perceived “unified” promise if not clearly messaged. - Risk: Gateway-induced header/proxy limitations can constrain token propagation; mitigation is short-lived viewer session tokens and minimized header set. - Risk: Strict no-warnings/no-deprecations gates can slow dependency upgrades; mitigate with explicit upgrade windows and pre-merge checks. -- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only fingerprint + approver id. +- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only the token fingerprint and `approverFingerprint`; the raw approver identifier remains validation input only and is never logged. diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md new file mode 100644 index 000000000..92b06e93f --- /dev/null +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -0,0 +1,108 @@ +# Audit identifier pseudonymization + +## Decision + +Clearfolio must not write raw approver identifiers or approval tokens to application logs. Policy-override audit events use a domain-separated keyed HMAC for the approver identifier and a non-reversible token fingerprint for the already high-entropy approval signature. Authentication-token handling is outside this policy-override logging contract and remains governed by the repository-wide logging and authorization controls. + +The approver field is named `approverFingerprint`, not `approverId`, so downstream log consumers cannot mistake pseudonymous data for the source identifier. Pseudonymized values remain personal data when they can be related back to a person using separately held information; they are not treated as anonymized data. + +## Cryptographic contract + +### Approver identifier + +The approver fingerprint is calculated as follows: + +```text +HMAC-SHA-256( + dedicated_audit_key, + UTF-8("clearfolio:audit-approver:v1\n" + exact_approver_identifier) +) +``` + +The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non-sensitive key version: + +```text +:<32 lowercase hexadecimal characters> +``` + +The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. + +A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior; a nonblank weak key fails application startup. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). + +Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected. + +### Approval token + +The approval token is a policy-override HMAC signature and is therefore already a high-entropy authentication value. The audit-only token fingerprint is calculated independently as follows: + +```text +SHA-256(UTF-8(exact_approval_token)) +``` + +The first eight digest bytes are encoded as 16 lowercase hexadecimal characters and written as `tokenFingerprint`. The fingerprint is unkeyed and has no domain prefix because it is used only as a short diagnostic correlation value for an already high-entropy signature; it must never be accepted as an authentication credential or used to validate a policy override. Null, empty, and blank approval tokens are rejected by request validation before fingerprinting, so the audit fingerprint function has no absent or empty sentinel contract. + +## Runtime secret loading + +Runtime key material is supplied through Spring Boot's config-tree property source rather than direct secret-bearing environment variables. The default mount is `/run/secrets/clearfolio/`; `CLEARFOLIO_SECRET_CONFIG_DIR` may select another bootstrap directory but must not contain a secret value. + +The secret store or orchestrator mounts files with these exact names: + +```text +conversion.policy-override-secret +conversion.audit-pseudonym-secret +conversion.audit-pseudonym-key-version +``` + +Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults. Production policy must require the needed values before enabling policy override operations. + +## Key ownership and rotation + +- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. +- The configured value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. +- The application startup guard rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. +- `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. +- Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. +- Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited. + +## Retention and access + +Audit log retention must be limited to the shortest period required by the documented security, contractual, and regulatory purpose. Read access is restricted by least privilege. Export, search, re-identification, and deletion workflows must be auditable. Logs and pseudonym keys must never be stored in the same access domain. + +## Incident response + +If the audit pseudonym key is suspected to be exposed: + +1. Rotate the key and version immediately. +2. Preserve affected log ranges under incident hold without broadening access. +3. Determine whether dictionary attacks against likely identifiers were feasible. +4. Treat exposed pseudonymized records as potentially exposed personal data. +5. Follow the applicable breach-assessment and notification process. +6. Verify that no raw identifiers, approval tokens, or key material were written to logs. + +## Verification requirements + +Automated tests must prove: + +- determinism within one key version and domain; +- separation across keys, versions, and domains; +- rejection of configured keys shorter than 32 UTF-8 bytes; +- rejection of invalid explicit key versions; +- startup rejection when policy and audit purposes reuse the same nonblank key; +- distinct absent, empty, and unavailable approver behavior; +- rejection of null, empty, or blank approval tokens before token fingerprinting; +- safe handling of Unicode and control characters; +- no raw approver identifier or approval token in captured policy-override audit output; +- stable failure behavior if the HMAC provider is unavailable; +- 100% JaCoCo line and branch coverage for the `com.clearfolio.viewer.*` production package. + +## References + +European Parliament and Council of the European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. *Official Journal of the European Union, L 119*, 1–88. + +National Institute of Standards and Technology. (2008). *The keyed-hash message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.198-1 + +National Institute of Standards and Technology. (2025, June 23). *Proposed withdrawal of FIPS 198-1, HMAC*. Computer Security Resource Center. https://csrc.nist.gov/News/2025/proposed-withdrawal-of-fips-198-1-hmac + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Turan, M. S., & Brandão, L. T. A. N. (2024). *Keyed-hash message authentication code (HMAC): Specification of HMAC and recommendations for message authentication* (NIST SP 800-224 Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-224.ipd diff --git a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java index a86fc9826..feb506222 100644 --- a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java +++ b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java @@ -21,6 +21,8 @@ public class ConversionProperties { private double retryBackoffMultiplier = 2.0; private long maxUploadSizeBytes = 5 * 1024 * 1024L; private String policyOverrideSecret = ""; + private String auditPseudonymSecret = ""; + private String auditPseudonymKeyVersion = "v1"; private long processingLeaseTimeoutMs = 60_000L; /** @@ -200,6 +202,44 @@ public void setPolicyOverrideSecret(String policyOverrideSecret) { this.policyOverrideSecret = policyOverrideSecret == null ? "" : policyOverrideSecret; } + /** + * Returns the dedicated secret used to pseudonymize audit identifiers. + * + * @return audit pseudonym secret + */ + public String getAuditPseudonymSecret() { + return auditPseudonymSecret; + } + + /** + * Sets the dedicated secret used to pseudonymize audit identifiers. + * + * @param auditPseudonymSecret audit pseudonym secret + */ + public void setAuditPseudonymSecret(String auditPseudonymSecret) { + this.auditPseudonymSecret = auditPseudonymSecret == null ? "" : auditPseudonymSecret; + } + + /** + * Returns the non-sensitive key version included in audit fingerprints. + * + * @return audit pseudonym key version + */ + public String getAuditPseudonymKeyVersion() { + return auditPseudonymKeyVersion; + } + + /** + * Sets the non-sensitive key version included in audit fingerprints. + * + * @param auditPseudonymKeyVersion audit pseudonym key version + */ + public void setAuditPseudonymKeyVersion(String auditPseudonymKeyVersion) { + this.auditPseudonymKeyVersion = auditPseudonymKeyVersion == null + ? "v1" + : auditPseudonymKeyVersion; + } + /** * Returns the processing lease timeout used by restart recovery. * diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java new file mode 100644 index 000000000..240fae064 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -0,0 +1,49 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.config.ConversionProperties; + +/** + * Fails application startup when policy signing and audit pseudonymization use + * the same configured key material. + * + *

The two HMAC purposes form separate security domains. Reusing one value + * would allow a holder of the audit key to create policy-override signatures, + * so nonblank configured values must remain distinct.

+ */ +@Component +public final class AuditKeySeparationGuard { + + /** + * Validates the bound conversion security configuration during bean startup. + * + * @param properties bound conversion configuration + */ + public AuditKeySeparationGuard(ConversionProperties properties) { + requireDistinct( + properties.getPolicyOverrideSecret(), + properties.getAuditPseudonymSecret() + ); + } + + static void requireDistinct(String policySecret, String auditSecret) { + if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { + return; + } + if (MessageDigest.isEqual( + policySecret.getBytes(StandardCharsets.UTF_8), + auditSecret.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalStateException( + "policy override and audit pseudonym keys must be different" + ); + } + } + + private static boolean isConfigured(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java new file mode 100644 index 000000000..ed3bd31db --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java @@ -0,0 +1,140 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Produces privacy-safe, domain-separated audit fingerprints for identifiers. + * + *

The pseudonymizer deliberately uses a dedicated keyed HMAC rather than an + * unkeyed digest. This prevents practical dictionary attacks against common + * low-entropy identifiers such as usernames, employee numbers, and email + * addresses. Fingerprints are stable only within the configured key version + * and domain.

+ */ +public final class AuditPseudonymizer { + + private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final String DEFAULT_KEY_VERSION = "v1"; + private static final String APPROVER_DOMAIN = "clearfolio:audit-approver:v1"; + private static final int FINGERPRINT_BYTES = 16; + private static final int MIN_SECRET_BYTES = 32; + private static final int MAX_KEY_VERSION_LENGTH = 32; + private static final HexFormat HEX_FORMAT = HexFormat.of(); + + private final byte[] secretBytes; + private final String keyVersion; + private final String domain; + + /** + * Creates an approver audit pseudonymizer. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + */ + public AuditPseudonymizer(String secret, String keyVersion) { + this(secret, keyVersion, APPROVER_DOMAIN); + } + + /** + * Creates a pseudonymizer with an explicit domain for isolated internal use + * and domain-separation verification. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + * @param domain stable protocol-specific domain separator + */ + AuditPseudonymizer(String secret, String keyVersion, String domain) { + this.secretBytes = configuredSecretBytes(secret); + this.keyVersion = normalizeKeyVersion(keyVersion); + this.domain = requireDomain(domain); + } + + /** + * Returns a stable keyed fingerprint without exposing the supplied identifier. + * + *

Null input is represented by a fixed absent marker. If no dedicated key + * is configured, the method emits a fixed non-correlatable unavailable marker + * instead of falling back to plaintext or an unkeyed hash. Empty input remains + * distinct from absent input and is HMACed exactly as supplied.

+ * + * @param identifier exact identifier bytes represented as a Java string + * @return versioned fingerprint or a fixed safe marker + */ + public String fingerprint(String identifier) { + if (identifier == null) { + return "absent:" + keyVersion; + } + if (secretBytes == null) { + return "unavailable:" + keyVersion; + } + + String payload = domain + "\n" + identifier; + try { + Mac mac = Mac.getInstance(HMAC_SHA_256); + mac.init(new SecretKeySpec(secretBytes, HMAC_SHA_256)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return keyVersion + ":" + HEX_FORMAT.formatHex(digest, 0, FINGERPRINT_BYTES); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("audit pseudonym HMAC unavailable", ex); + } + } + + private static byte[] configuredSecretBytes(String secret) { + String configuredSecret = cleanSecret(secret); + if (configuredSecret == null) { + return null; + } + + byte[] bytes = configuredSecret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_SECRET_BYTES) { + throw new IllegalArgumentException( + "audit pseudonym secret must contain at least 32 UTF-8 bytes" + ); + } + return bytes; + } + + private static String cleanSecret(String secret) { + if (secret == null || secret.isBlank()) { + return null; + } + return secret; + } + + private static String normalizeKeyVersion(String keyVersion) { + if (keyVersion == null) { + return DEFAULT_KEY_VERSION; + } + if (keyVersion.isEmpty()) { + throw new IllegalArgumentException("audit pseudonym key version must not be blank"); + } + if (keyVersion.length() > MAX_KEY_VERSION_LENGTH) { + throw new IllegalArgumentException("audit pseudonym key version is too long"); + } + for (int index = 0; index < keyVersion.length(); index++) { + char character = keyVersion.charAt(index); + boolean safe = Character.isLetterOrDigit(character) + || character == '.' + || character == '_' + || character == '-'; + if (!safe) { + throw new IllegalArgumentException("audit pseudonym key version contains unsafe characters"); + } + } + return keyVersion; + } + + private static String requireDomain(String domain) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("audit pseudonym domain is required"); + } + return domain; + } +} diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 95e672281..438846565 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -18,6 +18,7 @@ import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.exception.UnsupportedDocumentFormatException; +import com.clearfolio.viewer.security.AuditPseudonymizer; /** * Default document validator that enforces extension and size constraints. @@ -32,6 +33,7 @@ public class DefaultDocumentValidationService implements DocumentValidationServi private final Set blockedExtensions; private final long maxUploadSizeBytes; private final String policyOverrideSecret; + private final AuditPseudonymizer auditPseudonymizer; /** * Creates the validation service from conversion configuration values. @@ -42,6 +44,10 @@ public DefaultDocumentValidationService(ConversionProperties conversionPropertie this.blockedExtensions = conversionProperties.getBlockedExtensions(); this.maxUploadSizeBytes = conversionProperties.getMaxUploadSizeBytes(); this.policyOverrideSecret = conversionProperties.getPolicyOverrideSecret(); + this.auditPseudonymizer = new AuditPseudonymizer( + conversionProperties.getAuditPseudonymSecret(), + conversionProperties.getAuditPseudonymKeyVersion() + ); } /** @@ -113,9 +119,9 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe if (blockedExtension) { LOGGER.info( - "Blocked-format override accepted extension={} approverId={} tokenFingerprint={}", + "Blocked-format override accepted extension={} approverFingerprint={} tokenFingerprint={}", sanitizeForLog(extension), - sanitizeForLog(overrideApproverIdForAudit), + auditPseudonymizer.fingerprint(overrideApproverIdForAudit), tokenFingerprint(overrideTokenForAudit) ); } @@ -202,7 +208,6 @@ private String tokenFingerprint(String approvalToken) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(approvalToken.getBytes(StandardCharsets.UTF_8)); - // Reused HexFormat for performance return HEX_FORMAT.formatHex(hashed, 0, FINGERPRINT_TRUNCATE_BYTES); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 digest unavailable", ex); @@ -213,8 +218,6 @@ private String sanitizeForLog(final String value) { if (value == null) { return ""; } - // ⚡ Bolt: Single-pass string sanitization - // Avoids multiple allocations from chained replace() calls. StringBuilder sb = null; for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java index 76790b559..0d77ef62d 100644 --- a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java +++ b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java @@ -101,12 +101,22 @@ private static String normalizeHeader(final String value) { return sb == null ? value : sb.toString(); } + /** + * Returns a log-safe diagnostic representation. + * + *

The approval token and approver identifier are always redacted, even + * when absent, so callers cannot accidentally disclose either secret value + * or infer whether an approver identifier was supplied from this string.

+ * + * @return diagnostic text containing only the sanitized override flag and + * fixed redaction markers for sensitive headers + */ @Override public String toString() { return "PolicyOverrideRequest{" + "policyOverride='" + normalizeHeader(policyOverride) + '\'' + ", approvalToken='[redacted]'" - + ", approverId='" + normalizeHeader(approverId) + '\'' + + ", approverId='[redacted]'" + '}'; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index df4f72aa3..87a025b05 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,14 @@ spring: application: name: clearfolio-viewer + config: + # Runtime key material is mounted as a Spring Boot config tree rather than + # carried in process environment variables. The environment variable below + # selects only the bootstrap directory. Mount files named + # `conversion.policy-override-secret`, + # `conversion.audit-pseudonym-secret`, and + # `conversion.audit-pseudonym-key-version` in this directory. + import: "optional:configtree:${CLEARFOLIO_SECRET_CONFIG_DIR:/run/secrets/clearfolio/}" codec: max-in-memory-size: ${conversion.max-upload-size-bytes} @@ -9,7 +17,6 @@ conversion: - hwp - hwpx worker-threads: 4 - policy-override-secret: "${CONVERSION_POLICY_OVERRIDE_SECRET:}" queue-capacity: 200 max-retry-attempts: 3 retry-initial-delay-ms: 500 diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java new file mode 100644 index 000000000..9e1c4a6fa --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -0,0 +1,40 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class AuditKeySeparationGuardTest { + + @Test + void rejectsIdenticalConfiguredKeysDuringStartup() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("shared-key-material"); + properties.setAuditPseudonymSecret("shared-key-material"); + + assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + } + + @Test + void acceptsDistinctConfiguredKeys() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("policy-signing-key"); + properties.setAuditPseudonymSecret("audit-pseudonym-key"); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", null)); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(" ", "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", " ")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java new file mode 100644 index 000000000..5200269a4 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java @@ -0,0 +1,41 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the minimum configured key-strength contract for audit pseudonyms. + */ +class AuditPseudonymizerKeyStrengthTest { + + @Test + void rejectsConfiguredSecretShorterThanThirtyTwoUtf8Bytes() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcde", "v1") + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + + @Test + void acceptsConfiguredSecretWithThirtyTwoUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcdef", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } + + @Test + void measuresConfiguredSecretLengthAsUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("가나다라마바사아자차카타파하가나", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java new file mode 100644 index 000000000..ce9b01b7f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java @@ -0,0 +1,182 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AuditPseudonymizerTest { + + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + private static final String AUDIT_KEY_ONE = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY_TWO = "fedcba9876543210fedcba9876543210"; + + @Test + void producesDeterministicVersionedFingerprintForExactIdentifierBytes() { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "2026-08"); + + String first = pseudonymizer.fingerprint("Employee-007@example.com"); + String second = pseudonymizer.fingerprint("Employee-007@example.com"); + + assertEquals(first, second); + assertTrue(first.matches("2026-08:[0-9a-f]{32}")); + assertFalse(first.contains("Employee-007")); + } + + @Test + void separatesKeysVersionsAndDomains() { + String identifier = "approver-123"; + String baseline = new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier); + + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_TWO, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v2", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-subject:v1" + ).fingerprint(identifier) + ); + } + + @Test + void distinguishesAbsentEmptyAndUnavailableValues() { + AuditPseudonymizer configured = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + AuditPseudonymizer unavailableWhitespace = new AuditPseudonymizer(" ", "v1"); + AuditPseudonymizer unavailableNull = new AuditPseudonymizer(null, "v1"); + + assertEquals("absent:v1", configured.fingerprint(null)); + assertTrue(configured.fingerprint("").matches("v1:[0-9a-f]{32}")); + assertNotEquals(configured.fingerprint(null), configured.fingerprint("")); + assertEquals("unavailable:v1", unavailableWhitespace.fingerprint("approver")); + assertEquals("unavailable:v1", unavailableNull.fingerprint("approver")); + assertEquals("absent:v1", unavailableWhitespace.fingerprint(null)); + } + + @Test + void preservesUnicodeAndControlCharactersOnlyInsideTheHmacInput() { + String identifier = "승인자\n\u202E@example.com"; + String fingerprint = new AuditPseudonymizer(AUDIT_KEY_ONE, "unicode-v1") + .fingerprint(identifier); + + assertTrue(fingerprint.matches("unicode-v1:[0-9a-f]{32}")); + assertFalse(fingerprint.contains("승인자")); + assertFalse(fingerprint.contains("example.com")); + assertFalse(fingerprint.contains("\n")); + assertFalse(fingerprint.contains("\u202E")); + } + + @Test + void defaultsOnlyMissingKeyVersionAndRejectsInvalidExplicitValues() { + assertTrue(new AuditPseudonymizer(AUDIT_KEY_ONE, null).fingerprint("id").startsWith("v1:")); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " v1") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1 ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "bad/version") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "x".repeat(33)) + ); + assertTrue( + new AuditPseudonymizer(AUDIT_KEY_ONE, "valid._-9") + .fingerprint("id") + .startsWith("valid._-9:") + ); + } + + @Test + void rejectsMissingDomain() { + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", null) + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", " ") + ); + } + + @Test + void wrapsMissingHmacProviderAsStableInternalFailure() { + synchronized (SECURITY_PROVIDERS_LOCK) { + List removedProviders = hmacProviderPositions(); + for (ProviderPosition providerPosition : removedProviders) { + Security.removeProvider(providerPosition.provider().getName()); + } + try { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> pseudonymizer.fingerprint("approver") + ); + assertEquals("audit pseudonym HMAC unavailable", exception.getMessage()); + } finally { + removedProviders.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(providerPosition -> Security.insertProviderAt( + providerPosition.provider(), + providerPosition.position() + )); + } + } + } + + private static List hmacProviderPositions() { + Provider[] providers = Security.getProviders(); + List positions = new ArrayList<>(); + for (int index = 0; index < providers.length; index++) { + Provider provider = providers[index]; + if (provider.getService("Mac", "HmacSHA256") != null) { + positions.add(new ProviderPosition(provider, index + 1)); + } + } + return positions; + } + + private record ProviderPosition(Provider provider, int position) { + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java new file mode 100644 index 000000000..7cafcd862 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java @@ -0,0 +1,170 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceAuditTest { + + private static final String AUDIT_PSEUDONYM_SECRET = + "0123456789abcdef0123456789abcdef"; + + @Test + void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { + String approverId = "employee-007@example.com"; + String policySecret = "policy-signing-secret"; + String approvalToken = generateSignature(approverId, "hwp", policySecret); + ConversionProperties properties = configuredProperties( + policySecret, + AUDIT_PSEUDONYM_SECRET, + "rotation-7" + ); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=rotation-7:")); + assertTrue(auditLine.contains("tokenFingerprint=")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + assertFalse(auditLine.contains("approverId=")); + } + + @Test + void missingDedicatedAuditKeyUsesNonCorrelatableSentinel() { + String approverId = "employee-008"; + String policySecret = "policy-signing-secret"; + String approvalToken = generateSignature(approverId, "hwp", policySecret); + ConversionProperties properties = configuredProperties(policySecret, "", "v9"); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=unavailable:v9")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + } + + @Test + void auditConfigurationNullsUseDocumentedSafeDefaults() { + ConversionProperties properties = new ConversionProperties(); + + properties.setAuditPseudonymSecret(null); + properties.setAuditPseudonymKeyVersion(null); + + assertEquals("", properties.getAuditPseudonymSecret()); + assertEquals("v1", properties.getAuditPseudonymKeyVersion()); + } + + private static ConversionProperties configuredProperties( + String policySecret, + String auditSecret, + String keyVersion) { + ConversionProperties properties = new ConversionProperties(); + properties.setBlockedExtensions(Set.of("hwp", "hwpx")); + properties.setPolicyOverrideSecret(policySecret); + properties.setAuditPseudonymSecret(auditSecret); + properties.setAuditPseudonymKeyVersion(keyVersion); + return properties; + } + + private static String generateSignature(String approverId, String extension, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String payload = approverId.length() + ":" + approverId + extension; + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("test signature generation failed", ex); + } + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(DefaultDocumentValidationService.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "audit-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private String singleMessage() { + assertEquals(1, messages.size()); + return messages.getFirst(); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java index 00a34a26b..e8e7966bf 100644 --- a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java +++ b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java @@ -4,8 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -53,30 +53,41 @@ void ofCreatesDistinctInstanceWhenOnlyApproverHeaderIsPresent() { } @Test - void toStringRedactsApprovalToken() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true", "secret-token", "approver-1"); + void toStringRedactsApprovalTokenAndApproverIdentifier() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "true", + "secret-token", + "private-approver@example.com" + ); String rendered = request.toString(); assertTrue(rendered.contains("approvalToken='[redacted]'")); + assertTrue(rendered.contains("approverId='[redacted]'")); assertFalse(rendered.contains("secret-token")); + assertFalse(rendered.contains("private-approver@example.com")); } @Test - void toStringNormalizesControlCharactersInPrintableHeaders() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true\n", "secret-token", "approver\r\n1\t"); + void toStringNormalizesControlCharactersInPrintableOverrideFlag() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "tr\nue\t", + "secret-token", + "sensitive-user\r\n1\t" + ); String rendered = request.toString(); - assertTrue(rendered.contains("policyOverride='true_'")); - assertTrue(rendered.contains("approverId='approver__1_'")); + assertTrue(rendered.contains("policyOverride='tr_ue_'")); + assertTrue(rendered.contains("approverId='[redacted]'")); + assertFalse(rendered.contains("sensitive-user")); } @Test - void toStringHandlesNullPrintableHeaders() { + void toStringHandlesNullPrintableHeaderWithoutRevealingIdentityState() { String rendered = PolicyOverrideRequest.none().toString(); assertTrue(rendered.contains("policyOverride='null'")); - assertTrue(rendered.contains("approverId='null'")); + assertTrue(rendered.contains("approverId='[redacted]'")); } } From 28966e640eeae2119ac6fcee04419219ef33b00c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:05:39 +0900 Subject: [PATCH 002/178] test(security): require strong policy override keys --- .../security/AuditKeySeparationGuardTest.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index 9e1c4a6fa..ce6137655 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -9,11 +10,14 @@ class AuditKeySeparationGuardTest { + private static final String POLICY_KEY = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY = "fedcba9876543210fedcba9876543210"; + @Test void rejectsIdenticalConfiguredKeysDuringStartup() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("shared-key-material"); - properties.setAuditPseudonymSecret("shared-key-material"); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(POLICY_KEY); assertThrows( IllegalStateException.class, @@ -21,11 +25,32 @@ void rejectsIdenticalConfiguredKeysDuringStartup() { ); } + @Test + void rejectsConfiguredPolicyKeyShorterThanThirtyTwoUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("short-policy-key"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + @Test void acceptsDistinctConfiguredKeys() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("policy-signing-key"); - properties.setAuditPseudonymSecret("audit-pseudonym-key"); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("가나다라마바사아자차카"); assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } From 2e9c4a2f81584229acbf9dcc9ab3c36930a50092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:06:04 +0900 Subject: [PATCH 003/178] fix(security): reject weak policy override keys --- .../security/AuditKeySeparationGuard.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index 240fae064..3b45d45fb 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -8,28 +8,45 @@ import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when policy signing and audit pseudonymization use - * the same configured key material. + * Fails application startup when configured policy-signing key material is weak + * or when policy signing and audit pseudonymization reuse the same key. * - *

The two HMAC purposes form separate security domains. Reusing one value + *

The policy-override key protects an administrative authorization decision, + * so a configured value must contain at least 32 UTF-8 bytes. The policy and + * audit HMAC purposes also form separate security domains. Reusing one value * would allow a holder of the audit key to create policy-override signatures, * so nonblank configured values must remain distinct.

*/ @Component public final class AuditKeySeparationGuard { + private static final int MINIMUM_POLICY_SECRET_BYTES = 32; + /** * Validates the bound conversion security configuration during bean startup. * * @param properties bound conversion configuration */ public AuditKeySeparationGuard(ConversionProperties properties) { + requireStrongPolicySecret(properties.getPolicyOverrideSecret()); requireDistinct( properties.getPolicyOverrideSecret(), properties.getAuditPseudonymSecret() ); } + static void requireStrongPolicySecret(String policySecret) { + if (!isConfigured(policySecret)) { + return; + } + if (policySecret.getBytes(StandardCharsets.UTF_8).length + < MINIMUM_POLICY_SECRET_BYTES) { + throw new IllegalStateException( + "policy override key must contain at least 32 UTF-8 bytes" + ); + } + } + static void requireDistinct(String policySecret, String auditSecret) { if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { return; From ac70dd738acea7eeb311916eda1b2fa8f3782c82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:07:17 +0900 Subject: [PATCH 004/178] docs(security): document policy override key strength --- docs/security/2026-08-04-audit-pseudonymization.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 92b06e93f..6f39f2232 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -8,6 +8,12 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ## Cryptographic contract +### Policy override key + +A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup before any conversion endpoint can accept traffic. The startup gate measures encoded bytes rather than Java character count, does not log the supplied value, and remains independent of the audit-key separation check. + +Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024). + ### Approver identifier The approver fingerprint is calculated as follows: @@ -57,8 +63,9 @@ Spring reads each file's contents as the corresponding property. The deployment ## Key ownership and rotation +- `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager. - `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. -- The configured value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. +- The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. - The application startup guard rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. - `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. - Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. @@ -85,7 +92,8 @@ Automated tests must prove: - determinism within one key version and domain; - separation across keys, versions, and domains; -- rejection of configured keys shorter than 32 UTF-8 bytes; +- startup rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- acceptance of multibyte policy keys based on encoded byte length rather than character count; - rejection of invalid explicit key versions; - startup rejection when policy and audit purposes reuse the same nonblank key; - distinct absent, empty, and unavailable approver behavior; From 27106a53071465fc3b7439b1bfdf305aa58e373e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:37:26 +0000 Subject: [PATCH 005/178] fix: trigger CI due to strix timeout --- update_commit.sh | 1 + 1 file changed, 1 insertion(+) create mode 100755 update_commit.sh diff --git a/update_commit.sh b/update_commit.sh new file mode 100755 index 000000000..6e48d953f --- /dev/null +++ b/update_commit.sh @@ -0,0 +1 @@ +git commit --allow-empty -m "trigger CI: Retry due to Strix GitHub Models transient outage" From b3ad882680f81b2bbfeddac892ba71bb45af8ade Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:59:42 +0900 Subject: [PATCH 006/178] chore: remove stray CI trigger script --- update_commit.sh | 1 - 1 file changed, 1 deletion(-) delete mode 100755 update_commit.sh diff --git a/update_commit.sh b/update_commit.sh deleted file mode 100755 index 6e48d953f..000000000 --- a/update_commit.sh +++ /dev/null @@ -1 +0,0 @@ -git commit --allow-empty -m "trigger CI: Retry due to Strix GitHub Models transient outage" From 0e7770be5de992a7d4e24f4626508682bf9d7232 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:05:47 +0900 Subject: [PATCH 007/178] test: cover disabled policy signing startup path --- .../viewer/security/AuditKeySeparationGuardTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index ce6137655..5d44c4d56 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -55,6 +55,13 @@ void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } + @Test + void permitsMissingPolicyKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + @Test void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); From a86ab46c3214c803a90f51deec3d4d8723699667 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:06:15 +0900 Subject: [PATCH 008/178] docs: consolidate unreleased changelog entries --- CHANGELOG.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5ba27b2..a1a683cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,31 +1,34 @@ +# Changelog + ## [Unreleased] + ### Added + - **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. +- **관리자용 단건 작업 삭제 및 재시도 API 추가** + - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. + - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. +- **비동기 버튼 로딩 피드백 및 상태 복원 개선** + - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. + - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. ### Changed + - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. ### Security + - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. -# Changelog - -## [Unreleased] - -### 추가된 기능 (Added) -- **관리자용 단건 작업 삭제 및 재시도 API 추가** - - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. - -- **비동기 버튼 로딩 피드백 및 상태 복원 개선** - - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Fixed +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. ## [0.1.0] - 2026-06-25 ### 추가된 기능 (Added) + - **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)** - 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다. - 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다. @@ -41,9 +44,11 @@ - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다. ### 테스트 커버리지 (Tests) + - 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다. ### 보안 (Security) + - **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다. - Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다. - Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다. @@ -52,6 +57,3 @@ - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. - logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. - -### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file From eb343235981880e994e2e91a841cdf67e428e970 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:11:12 +0900 Subject: [PATCH 009/178] build: enforce zero missed production lines and branches --- pom.xml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pom.xml b/pom.xml index 51e22d13e..0710a5a0d 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ ${java.version} UTF-8 + 0.8.15 3.0.8 6.1.200 + 4.1.136.Final M[Maven resolves
Spring Boot + netty.version] + M --> C[CycloneDX Maven Plugin 2.9.1
makeAggregateBom] + C --> B[target/bom.json
CycloneDX 1.6] + B --> V[Graph verifier
61 components / 17 Netty] + V --> A[Deterministic attribution renderer] + B --> I[Immutable Actions artifact
ID 8929593015] + A --> I + I --> D[Committed buyer data-room evidence] + D --> T[Permanent drift and dependency tests] +``` + +The exact generation command is: + +```bash +mvn -B --no-transfer-progress -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -Dcyclonedx.skipAttach=true \ + -DoutputFormat=json \ + -DoutputName=bom +``` + +The plugin writes the canonical JSON document to `target/bom.json`. The `outputFormat` and `outputName` parameters are Maven user properties without a `cyclonedx.` prefix; only `cyclonedx.skipAttach` uses that prefix in this invocation. + +## Evidence record + +Read-only workflow run `31004040777` generated the accepted evidence from source head `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` at `2026-08-05T12:07:15Z`. + +- Artifact ID: `8929593015` +- Artifact archive SHA-256: `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` +- CycloneDX version: `1.6` +- Total components: `61` +- Netty components: `17` +- Netty version set: exactly `4.1.136.Final` +- SBOM SHA-256: `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` +- Attribution SHA-256: `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` + +The graph verifier proved that every Netty dependency reference has a corresponding current Netty component ref and that `4.1.135.Final` is absent from both generated files. The attribution renderer was rerun from the generated JSON and matched the committed Markdown byte contract. + +The committed SBOM and attribution are shareable buyer evidence. Workflow logs and the one-day artifact are transient generation provenance and must not be presented as durable data-room evidence after expiry. Reproduction therefore depends on the documented command, exact source revision, immutable plugin version, committed hashes, permanent drift tests, and fresh exact-head CI. ## Security and compatibility boundaries @@ -31,17 +80,19 @@ The override is guarded by `DependencyPolicyTest.pomPinsPatchedNettyLineForReact - Maven must resolve all applicable `io.netty` modules to `4.1.136.Final`; stale modules at `4.1.135.Final` or an older version are a release blocker. - Existing zero-missed-line and zero-missed-branch JaCoCo gates, compiler warnings-as-errors, fuzzing, SAST, dependency review, OSV, Trivy, Scorecard, Strix, and independent review remain mandatory. - A successful unit-test run does not replace dependency-tree and security-scanner evidence. -- The override must not be copied into downstream modules as separate ad hoc pins. Shared consumers should inherit the version through the root build or a versioned central BOM contract. +- The override must not be copied into downstream modules as separate ad hoc pins. Standalone builds inherit the root property; modular consumers should use a versioned BOM or equivalent explicit contract. +- The evidence record describes the dependency graph of its exact generation head. Any dependency change requires regeneration and a new evidence hash record. ## Verification For the exact pull-request head: 1. Run `mvn -B --no-transfer-progress verify`. -2. Inspect `mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty` and confirm one coherent `4.1.136.Final` line for applicable Netty modules. -3. Require successful CI, Security Scan, SAST Semgrep, fuzzing, CodeRabbit/OpenCode/Noema review, and Strix evidence for the same head. -4. Reject cancelled, skipped-required, stale-head, or manually inferred results. -5. Preserve an independent approving review that GitHub counts under protected-branch rules. +2. Run `mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty` and confirm one coherent `4.1.136.Final` line for every applicable Netty module. +3. Run `python3 scripts/test_render_third_party_attribution.py` and require the generated graph and attribution drift contract to pass. +4. Require successful CI, Security Scan, SAST Semgrep, every fuzz target, CodeRabbit, Strix, OpenCode, and Noema evidence for the same head. +5. Reject queued, cancelled, skipped-required, stale-head, local-only, or manually inferred results. +6. Preserve an independent approving review that GitHub counts under protected-branch rules. ## Removal and upgrade rule @@ -50,27 +101,31 @@ Keep this override until one of the following occurs: - the Spring Boot parent used by Clearfolio manages Netty `4.1.136.Final` or a later reviewed compatible release; or - Clearfolio moves to a different supported reactive HTTP stack through an accepted architecture decision. -Removing or increasing the override requires the same exact-head dependency-tree, compatibility, security, coverage, and review evidence. A newer version number alone is not proof of compatibility. +Removing or increasing the override requires the same exact-head dependency-tree, compatibility, security, coverage, SBOM regeneration, and review evidence. A newer version number alone is not proof of compatibility. ## Consequences ### Positive -- The complete Netty family moves to the reviewed fixed 4.1 patch line. -- The remediation is expressed through Spring Boot's documented version-property mechanism rather than fragile per-artifact pins. -- A real-project contract test prevents silent regression when dependency management is edited. -- The decision and evidence remain auditable for buyer security review and future upgrades. +- The complete Netty family moves to one reviewed fixed 4.1 patch line. +- The remediation uses Spring Boot's documented version-property mechanism rather than fragile per-artifact pins. +- Real-project and generated-evidence contracts prevent silent dependency or data-room drift. +- Exact generation provenance, hashes, and local-versus-shareable evidence boundaries remain auditable for acquisition diligence. ### Trade-offs - Clearfolio temporarily diverges from the Netty patch version selected by Spring Boot 3.5.16. - The project must retain explicit exact-head compatibility and scanner evidence until the parent line catches up. -- A future parent upgrade must reconcile this property deliberately rather than assuming it is obsolete. +- A future parent upgrade must reconcile this property deliberately and regenerate the buyer evidence. ## References +CycloneDX Project. (n.d.). *CycloneDX Maven plugin* [Source code]. GitHub. Retrieved August 5, 2026, from https://github.com/CycloneDX/cyclonedx-maven-plugin + Netty Project. (2026, July 8). *Netty 4.1.136.Final* [Software release]. GitHub. https://github.com/netty/netty/releases/tag/netty-4.1.136.Final -Spring. (2026). *Managed dependency coordinates: Spring Boot 3.5.16*. https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/coordinates.html +OWASP Foundation. (2024, April 9). *CycloneDX 1.6* [Software bill of materials specification]. https://github.com/CycloneDX/specification/releases/tag/1.6 + +Spring. (n.d.). *Managed dependency coordinates: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/coordinates.html -Spring. (2026). *Version properties: Spring Boot 3.5.16*. https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/properties.html +Spring. (n.d.). *Version properties: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/properties.html From 67cd42c435865b565c52697019aba927eddd6a0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:04:12 +0900 Subject: [PATCH 033/178] docs(evidence): correct CycloneDX generation contract --- .../2026-07-02-krw2b-sale-readiness/README.md | 230 +++++++++--------- 1 file changed, 110 insertions(+), 120 deletions(-) diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md index d8f2b78b6..a071f400f 100644 --- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md +++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md @@ -1,40 +1,49 @@ # KRW 2B Sale-Readiness Evidence -Date: 2026-07-02 -Verification source head SHA before this evidence refresh: -`7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd` +Original evidence date: 2026-07-02 +Original verification source head: `7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd` +Latest dependency-evidence refresh: 2026-08-05 +Netty SBOM generation source head: `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` + +## Evidence Boundary + +This directory combines a historical sale-readiness snapshot with selected generated artifacts that remain under executable drift contracts. A `Pass` result describes the named artifact and its source revision; it is not automatically transferable to a later source head. + +The committed CycloneDX JSON and generated third-party attribution are shareable buyer data-room evidence. GitHub Actions logs and the one-day generation artifact are transient provenance. Any dependency change must regenerate the SBOM, attribution, hashes, and exact-head acceptance evidence before release. ## Gate Summary | Gate | Result | Evidence | | --- | --- | --- | -| Java runtime | Pass, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` | -| Compile warnings/deprecations | Pass | `compile.log` | -| Tests + JaCoCo | Pass, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` | -| JavaDoc | Pass, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` | -| Markdown lint | Pass, 0 errors across changed docs | `markdownlint.log` | -| JS syntax | Pass | `node-check.log` | -| SAST | Pass, 0 findings | `semgrep.log`, `semgrep.json` | -| SBOM | Pass, CycloneDX 1.6, 61 components, 0 components without license metadata | `sbom-cyclonedx.log`, `sbom-cyclonedx.json`, `sbom-status.txt` | -| License review | Pass, buyer-release policy checker reports 61 allowed components, 0 review-required components, 0 unlisted violations, and passes `--require-no-review` | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` | -| Third-party attribution | Pass, generated buyer data-room attribution contains all 61 current SBOM components and passes drift check | `docs/legal/2026-07-03-third-party-attribution.md`, `third-party-attribution-check.log` | -| Buyer data-room manifest | Pass, manifest references required buyer evidence artifacts, all local paths exist, and ready gates reference only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` | -| Buyer readiness scorecard | Pass, generated scorecard reports 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness, and ready-gate evidence integrity pass from the current data-room manifest | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` | -| Figma Slides generation payload | Pass, payload check reports 11 slides, 4 objectives, and 0 errors; actual Slides generation still requires Figma team or organization plan selection | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` | -| Auth/tenant, signed artifacts, and KPI snapshots | Partial, runtime tenant enforcement, optional gateway HMAC tenant-claim validation, production-profile fail-closed startup without signed tenant secret, signed artifact tokens, token revocation, artifact read audit API, optional file-backed artifact-link ledger replay, optional file-backed KPI snapshot ledger replay, and tenant-scoped KPI snapshot export API implemented; OIDC/JWT and centralized durable revocation/audit/analytics persistence pending | `docs/security/2026-07-02-auth-tenant-model.md`, `docs/security/2026-07-02-signed-artifact-link-design.md`, auth/artifact/analytics tests | -| Buyer deployment integration | Pass for buyer sandbox scope; `buyer-demo` Spring profile, gateway-signed header contract, connector API table, OpenAPI connector seed, smoke path, and cutover gates are documented; buyer tenant import and production OIDC/JWT profile remain follow-up | `src/main/resources/application-buyer-demo.yml`, `docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md`, `docs/deployment/clearfolio-buyer-connector.openapi.yaml` | -| Durable job repository design, state-store, lifecycle event, and recovery sweep slice | Partial, code boundary implemented; `ConversionJobStateStore` routes worker success/failure and operator retry transitions, `ConversionJobLifecycleEvent` records process-local append-only transition evidence, and `DefaultConversionWorker` now re-enqueues due submitted jobs plus stale processing leases from available repository state, while SQL persistence remains pending for true process-restart durability | `docs/persistence/2026-07-02-durable-conversion-job-repository-plan.md`, state-store, lifecycle event, and recovery sweep tests | -| Seeded buyer-demo screenshots | Pass for local screenshot scope; seeded desktop and mobile viewports render after `Load demo story`, with no mobile horizontal overflow and uploaded FigJam screenshot nodes `25:1423` and `25:1422` | `seeded-demo-story-verification.md`, `screenshots/seeded-demo-desktop-viewport.png`, `screenshots/seeded-demo-mobile-viewport.png` | -| Buyer diligence closure map | Pass for FigJam handoff scope; added `Clearfolio KRW 2B Buyer Diligence Closure Map`, `Clearfolio Buyer Readiness Scorecard Gate Map`, and `Clearfolio Buyer Diligence Slides Storyboard` on the existing evidence board, and captured Slides generation prerequisites plus deck outline | `docs/design/2026-07-03-buyer-diligence-slides-and-closure-map.md`, `docs/design/2026-07-02-buyer-demo-kpi-figjam-handoff.md` | -| Local smoke | Pass, signed tenant claims plus file-backed artifact/KPI ledgers, KPI snapshot export API, buyer-demo KPI evidence panel, and operator recovery evidence panel | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` | -| GitHub PR state | Seeded buyer-demo story branch is refreshed on current `main`; review and queued checks are not treated as blockers | PR body and GitHub UI | +| Java runtime | Pass for original snapshot, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` | +| Compile warnings/deprecations | Pass for original snapshot | `compile.log` | +| Tests + JaCoCo | Pass for original snapshot, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` | +| JavaDoc | Pass for original snapshot, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` | +| Markdown lint | Pass for original snapshot, 0 errors across changed docs | `markdownlint.log` | +| JS syntax | Pass for original snapshot | `node-check.log` | +| SAST | Pass for original snapshot, 0 findings | `semgrep.log`, `semgrep.json` | +| SBOM | Refreshed 2026-08-05, CycloneDX 1.6, 61 components, 17 Netty components at `4.1.136.Final`, 0 components without license metadata | `sbom-cyclonedx.json`, Netty ADR, permanent drift test | +| License review | Pass for current 61-component generated SBOM; 0 review-required and 0 unlisted violations under buyer-release policy | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` | +| Third-party attribution | Refreshed from the same generated SBOM and protected by byte-for-byte renderer drift validation | `docs/legal/2026-07-03-third-party-attribution.md`, `scripts/test_render_third_party_attribution.py` | +| Buyer data-room manifest | Pass for original snapshot; required local paths existed and ready gates cited only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` | +| Buyer readiness scorecard | Pass for original snapshot; 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` | +| Figma Slides generation payload | Pass for payload scope; 11 slides, 4 objectives, 0 errors; actual Slides generation still requires an eligible Figma plan | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` | +| Auth/tenant, signed artifacts, and KPI snapshots | Partial; runtime tenant enforcement, signed claims, signed artifact tokens, revocation, audit, and file-backed ledgers exist, while production OIDC/JWT and centralized durable persistence remain pending | Security model, artifact, analytics, and persistence tests | +| Buyer deployment integration | Pass for buyer sandbox scope; connector seed, gateway-signed claims, smoke path, and cutover gates documented | Buyer deployment playbook, connector OpenAPI, buyer-demo profile | +| Durable job repository and recovery slice | Partial; code boundary, state store, lifecycle events, and process-local recovery exist, while SQL process-restart durability remains pending | Persistence plan and repository/state-store tests | +| Seeded buyer-demo screenshots | Pass for local screenshot scope; desktop/mobile seeded story, no mobile overflow | Seeded demo verification and screenshots | +| Buyer diligence closure map | Pass for FigJam handoff scope | Design handoff documentation | +| Local smoke | Pass for original signed-tenant, artifact-ledger, KPI-ledger, viewer, revocation, and recovery scope | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` | +| GitHub PR state | Dynamic; queued or waiting review does not stop productive work but is never counted as merge acceptance | Current exact-head PR checks and reviews | ## SAST -Command: +Command used for the original evidence snapshot: ```bash -uvx semgrep --config p/java --metrics=off --error --json --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json src/main/java src/test/java +uvx semgrep --config p/java --metrics=off --error --json \ + --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json \ + src/main/java src/test/java ``` Result: @@ -45,53 +54,77 @@ Result: - Findings: 0. - Errors: 0. -Evidence: - -- `semgrep.json` +Evidence: `semgrep.json`. -## SBOM +## SBOM Generation -Command: +### Canonical command ```bash -mvn -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -Dcyclonedx.skipAttach=true -Dcyclonedx.outputFormat=json -Dcyclonedx.outputName=clearfolio-viewer-sbom +mvn -B --no-transfer-progress -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -Dcyclonedx.skipAttach=true \ + -DoutputFormat=json \ + -DoutputName=bom ``` -Result: +CycloneDX Maven Plugin 2.9.1 writes the canonical JSON output to `target/bom.json`. `outputFormat` and `outputName` are Maven user properties without a `cyclonedx.` prefix. The earlier evidence command incorrectly prefixed those two properties and is superseded by this contract. + +### Deterministic provenance + +Read-only workflow run `31004040777` generated the accepted dependency evidence at `2026-08-05T12:07:15Z`. + +| Field | Value | +| --- | --- | +| Source head | `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` | +| Generator | `org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom` | +| Artifact ID | `8929593015` | +| Artifact archive SHA-256 | `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` | +| SBOM SHA-256 | `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` | +| Attribution SHA-256 | `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` | +| CycloneDX specification | `1.6` | +| Total components | `61` | +| Netty components | `17` | +| Netty version set | exactly `4.1.136.Final` | +| Components without license metadata | `0` | + +```mermaid +flowchart LR + H[Exact source head] --> R[Maven dependency resolution] + R --> G[CycloneDX 2.9.1] + G --> B[target/bom.json] + B --> V[Component and edge verifier] + V --> A[Attribution renderer] + B --> C[Committed SBOM] + A --> D[Committed attribution] + C --> T[Permanent drift test] + D --> T +``` + +The verifier requires every Netty component version, purl, bom-ref, and dependency edge to resolve to `4.1.136.Final`. It rejects the historical `4.1.135.Final` line, an empty component list, unmatched dependency references, or attribution that cannot be reproduced from the committed JSON. + +### Current generated result - CycloneDX BOM format: 1.6. - Components: 61. - Components without license metadata: 0. - Unique license metadata entries: 3. -- Engineering license review is now documented in - `docs/security/2026-07-02-license-allowlist-review.md`. -- The unused `tika-parsers-standard-package` dependency was removed, which - eliminated Tika transitive review-required components `jhighlight`, `junrar`, - and `juniversalchardet` from the current SBOM. -- Spring Boot's default Logback starter was replaced with - `spring-boot-starter-log4j2`, and `jakarta.annotation-api` is excluded from - the current starter paths. -- The standard-library license policy checker passes buyer-release mode: - 61 allowed components, 0 review-required components, and 0 unlisted - violations with `--require-no-review`. -- The standard-library attribution renderer generates - `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM and - the drift check confirms that the data-room attribution file is current. -- The buyer data-room manifest checker confirms the sale-readiness package links - to required local evidence and current Figma/GitHub handoff URLs, and prevents - ready gates from citing partial or external artifacts as complete evidence. -- The buyer readiness scorecard generator reports 23 current data-room - artifacts, 8 readiness gates, 38 percent conservative gate readiness, and - ready-gate evidence integrity pass while keeping partial gates as discount - risks. -- The Figma Slides payload checker confirms the buyer diligence deck payload has - 11 slides, 4 objectives, explicit no-Code-Connect wording, readiness - scorecard content, discount-risk content, and claim-boundary wording. +- The unused `tika-parsers-standard-package` dependency remains absent, eliminating Tika transitive review-required components `jhighlight`, `junrar`, and `juniversalchardet` from the buyer-release graph. +- Spring Boot's default Logback starter is replaced with `spring-boot-starter-log4j2`, and `jakarta.annotation-api` remains excluded from the current starter paths. +- The standard-library attribution renderer generates `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM. +- The buyer-release license policy records 61 allowed components, 0 review-required components, and 0 unlisted violations. -Evidence: +Primary generated evidence: -- `sbom-cyclonedx.log` - `sbom-cyclonedx.json` +- `docs/legal/2026-07-03-third-party-attribution.md` +- `docs/security/2026-08-05-netty-4.1.136-remediation.md` +- `scripts/test_render_third_party_attribution.py` +- `src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java` + +Related historical and buyer-handoff evidence: + +- `sbom-cyclonedx.log` - `sbom-status.txt` - `license-policy.log` - `license-policy-summary.json` @@ -101,7 +134,6 @@ Evidence: - `buyer-readiness-scorecard-summary.json` - `figma-deck-payload-check.json` - `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json` -- `docs/legal/2026-07-03-third-party-attribution.md` - `docs/security/2026-07-02-license-allowlist-review.md` - `docs/security/2026-07-02-license-policy.json` - `docs/security/2026-07-02-auth-tenant-model.md` @@ -116,78 +148,36 @@ Evidence: - `docs/superpowers/plans/2026-07-02-conversion-job-lifecycle-events.md` - `docs/superpowers/plans/2026-07-03-conversion-recovery-sweep.md` - `buyer-deployment-slice-verification.md` -- FigJam diagrams: - [Clearfolio Gateway Signed Tenant Claims Flow](https://www.figma.com/board/114nJPcTcQzXvAEIS9T4gM) - and `Clearfolio KPI Snapshot Evidence Ledger Flow` plus - `Clearfolio KPI Snapshot Export Evidence API Flow` and - `Clearfolio Buyer Demo KPI Evidence Panel Flow` plus - `Clearfolio Operator Recovery Evidence Flow` and - `Clearfolio Conversion State Store Implementation Flow` plus - `Clearfolio Conversion Lifecycle Event Trail Flow` plus - `Clearfolio Buyer Readiness Scorecard Gate Map` plus - `Clearfolio Buyer Diligence Slides Storyboard` plus - `Clearfolio Ready Gate Evidence Integrity Check` plus - `Clearfolio Conversion Recovery Sweep Flow`. + +FigJam handoff includes the gateway signed-tenant flow, KPI snapshot ledger/export flows, buyer-demo KPI panel, operator recovery flow, conversion state-store and lifecycle-event flows, buyer readiness gate map, diligence slides storyboard, ready-gate evidence integrity check, and conversion recovery sweep flow. ## Local Smoke -Command path: +Original command path: -- Start app on a random local port with - `clearfolio.tenant-claims.hmac-secret` and - `clearfolio.artifact-link-ledger.path` plus - `clearfolio.analytics-snapshot-ledger.path` configured. -- Runtime Java: 21.0.11. -- Verify `GET /`, buyer-demo KPI evidence panel markup, - buyer-demo operator recovery evidence panel markup, `/assets/viewer/demo.js`, - demo JS KPI export endpoint reference, - missing-auth KPI denial, unsigned tenant-claim KPI denial, authenticated empty - KPI snapshot with signed tenant claims, authenticated empty KPI export lookup, - document upload with signed tenant headers, status polling to `SUCCEEDED`, - `/viewer/{docId}`, authenticated viewer bootstrap, signed artifact URL - creation, unsigned artifact denial, signed artifact range access, artifact - read audit lookup, artifact token revocation, revoked-token denial, - cross-tenant status denial, post-upload KPI snapshot, post-upload KPI export - lookup, and file-backed KPI snapshot ledger append evidence. +- Start the application on a random local port with `clearfolio.tenant-claims.hmac-secret`, `clearfolio.artifact-link-ledger.path`, and `clearfolio.analytics-snapshot-ledger.path` configured. +- Verify the root shell, buyer-demo KPI and recovery panels, demo assets, signed claims, upload and status polling, viewer/bootstrap, signed and ranged artifact access, read audit, revocation, cross-tenant concealment, KPI snapshots and exports, and file-backed ledger append evidence. -Result: +Original result: -- Root shell: 200. -- Root shell evidence panel: present. -- Root shell operator recovery panel: present. -- Demo JS: 200. -- Demo JS KPI export endpoint reference: present. -- Missing-auth KPI: 401. -- Unsigned tenant-claim KPI with secret configured: 401. -- Authenticated empty KPI: 200. -- Authenticated empty KPI exports: 200, 1 record, tenant id omitted. -- Final conversion status: `SUCCEEDED`. -- Status tenant: `buyer-demo`. -- Viewer HTML: 200. -- Viewer bootstrap: 200. -- Artifact link creation: 200. -- Unsigned artifact read: 401. -- Signed artifact range read: 206. -- Artifact read audit lookup: 200, 1 event, last status 206. -- Artifact token revocation: 200, `revoked=true`. -- Revoked artifact read: 403. +- Runtime Java: 21.0.11. +- Root shell: 200; evidence and recovery panels present. +- Missing or unsigned tenant claims: 401. +- Authenticated empty KPI and exports: 200. +- Final conversion status: `SUCCEEDED` for tenant `buyer-demo`. +- Viewer and bootstrap: 200. +- Signed artifact range read: 206; unsigned read: 401. +- Artifact read audit: 200; revocation succeeded; revoked read: 403. - Cross-tenant status lookup: 404. -- Post-upload KPI: `totalJobs=1`, `succeededJobs=1`, - `conversionSuccessRate=1.0`, numeric `p95TimeToPreviewMs`. -- Post-upload KPI exports: 200, 2 records, latest `totalJobs=1`, tenant id - omitted. -- Artifact ledger file: present, 2 `ISSUED` lines, 1 `REVOKED` line, - and 1 `READ` line. -- KPI snapshot ledger file: present, 2 `SNAPSHOT` lines. +- Post-upload KPI: one successful job and numeric preview latency. +- Artifact and KPI ledger append evidence present. Evidence: - `smoke-local.txt` - `smoke-ui-root.txt` +- `smoke-app.log` -## GitHub Checks +## GitHub Acceptance -This evidence refresh was produced locally before publishing the recovery-sweep -branch. The PR body should carry the local gate results from this file. Review -and queued GitHub checks are not treated as blockers for continuing the -sale-readiness work. +The historical snapshot is not a substitute for current pull-request evidence. A release or merge requires the exact current head to pass repository CI, Maven `verify`, zero missed production lines and branches, warning-free public Javadocs, Security Scan, SAST, every fuzz target, dependency/security review, current automated review, zero unresolved threads, and a counted independent approval. Queued, pending, cancelled, skipped-required, stale-head, or local-only results are not passing. From 06452c3f39f2deb38d31d189e46de4b25512baa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:05:38 +0900 Subject: [PATCH 034/178] docs(changelog): record deterministic Netty buyer evidence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d7e5a78..4c5ca2e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. - Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. - Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. +- CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다. ### Security From 1907593d4c4e79c9dc6506fe4a5f8c9bf01bf3bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:26:29 +0900 Subject: [PATCH 035/178] test(security): require auditable policy overrides --- .../security/AuditKeySeparationGuardTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index 5d44c4d56..9b896b758 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -25,6 +25,19 @@ void rejectsIdenticalConfiguredKeysDuringStartup() { ); } + @Test + void rejectsEnabledPolicySigningWithoutAnAuditPseudonymKey() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret(POLICY_KEY); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } + @Test void rejectsConfiguredPolicyKeyShorterThanThirtyTwoUtf8Bytes() { ConversionProperties properties = new ConversionProperties(); @@ -51,6 +64,7 @@ void acceptsDistinctConfiguredKeys() { void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { ConversionProperties properties = new ConversionProperties(); properties.setPolicyOverrideSecret("가나다라마바사아자차카"); + properties.setAuditPseudonymSecret(AUDIT_KEY); assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } @@ -62,6 +76,14 @@ void permitsMissingPolicyKeyWhenPolicySigningIsDisabled() { assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } + @Test + void permitsAnAuditKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + @Test void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); From 0189136007262642daa4674766299e73832beb7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:28:25 +0900 Subject: [PATCH 036/178] fix(security): fail closed without override audit key --- .../security/AuditKeySeparationGuard.java | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index 3b45d45fb..cd38cedd8 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -8,14 +8,16 @@ import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when configured policy-signing key material is weak - * or when policy signing and audit pseudonymization reuse the same key. + * Fails application startup when policy-override key material cannot support a + * private and attributable administrative audit trail. * *

The policy-override key protects an administrative authorization decision, - * so a configured value must contain at least 32 UTF-8 bytes. The policy and - * audit HMAC purposes also form separate security domains. Reusing one value - * would allow a holder of the audit key to create policy-override signatures, - * so nonblank configured values must remain distinct.

+ * so a configured value must contain at least 32 UTF-8 bytes. Enabling that + * signing key also requires a dedicated audit pseudonym key: accepting an + * override while emitting only an unavailable marker would prevent operators + * from distinguishing approvers during an investigation. The policy and audit + * HMAC purposes remain separate security domains, and their configured values + * must therefore be distinct.

*/ @Component public final class AuditKeySeparationGuard { @@ -28,11 +30,11 @@ public final class AuditKeySeparationGuard { * @param properties bound conversion configuration */ public AuditKeySeparationGuard(ConversionProperties properties) { - requireStrongPolicySecret(properties.getPolicyOverrideSecret()); - requireDistinct( - properties.getPolicyOverrideSecret(), - properties.getAuditPseudonymSecret() - ); + String policySecret = properties.getPolicyOverrideSecret(); + String auditSecret = properties.getAuditPseudonymSecret(); + requireStrongPolicySecret(policySecret); + requireAuditKeyWhenPolicySigningIsEnabled(policySecret, auditSecret); + requireDistinct(policySecret, auditSecret); } static void requireStrongPolicySecret(String policySecret) { @@ -47,6 +49,17 @@ static void requireStrongPolicySecret(String policySecret) { } } + static void requireAuditKeyWhenPolicySigningIsEnabled( + String policySecret, + String auditSecret + ) { + if (isConfigured(policySecret) && !isConfigured(auditSecret)) { + throw new IllegalStateException( + "audit pseudonym key is required when policy override signing is enabled" + ); + } + } + static void requireDistinct(String policySecret, String auditSecret) { if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { return; From c57af8be953445bdfd89ddf24aa331046315a7ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:29:41 +0900 Subject: [PATCH 037/178] docs(security): require auditable override startup --- .../security/2026-08-04-audit-pseudonymization.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 6f39f2232..98d440598 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -10,7 +10,7 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ### Policy override key -A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup before any conversion endpoint can accept traffic. The startup gate measures encoded bytes rather than Java character count, does not log the supplied value, and remains independent of the audit-key separation check. +A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup before any conversion endpoint can accept traffic. Configuring a valid policy-override key without a dedicated audit pseudonym key also fails startup, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The startup gates measure encoded bytes rather than Java character count and never log supplied key material. Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024). @@ -31,9 +31,9 @@ The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non- :<32 lowercase hexadecimal characters> ``` -The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. +The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents application startup. -A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior; a nonblank weak key fails application startup. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). +A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing key, fails application startup. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected. @@ -59,14 +59,14 @@ conversion.audit-pseudonym-secret conversion.audit-pseudonym-key-version ``` -Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults. Production policy must require the needed values before enabling policy override operations. +Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise startup fails before traffic is accepted. ## Key ownership and rotation - `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager. -- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. +- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. It is mandatory whenever `conversion.policy-override-secret` is configured. - The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. -- The application startup guard rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. +- The application startup guard rejects an enabled policy-signing key without a configured audit key and rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. - `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. - Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. - Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited. @@ -93,10 +93,11 @@ Automated tests must prove: - determinism within one key version and domain; - separation across keys, versions, and domains; - startup rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- startup rejection when policy-override signing is enabled without a configured audit pseudonym key; - acceptance of multibyte policy keys based on encoded byte length rather than character count; - rejection of invalid explicit key versions; - startup rejection when policy and audit purposes reuse the same nonblank key; -- distinct absent, empty, and unavailable approver behavior; +- distinct absent, empty, and unavailable approver behavior while policy signing is disabled; - rejection of null, empty, or blank approval tokens before token fingerprinting; - safe handling of Unicode and control characters; - no raw approver identifier or approval token in captured policy-override audit output; From 50735738df84603fdf027b3a01f4545113b5542f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:30:43 +0900 Subject: [PATCH 038/178] docs(changelog): record auditable override gate --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5ca2e87..4b3ce9e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ ### Security - Spring Boot 3.5.16이 관리하던 Netty `4.1.135.Final` 전이 의존성 전체를 Spring Boot의 공식 `netty.version` 속성을 통해 `4.1.136.Final`로 정렬했습니다. 실제 POM을 읽는 회귀 테스트와 보안 ADR을 추가해 개별 Netty 모듈의 혼합 버전 및 향후 무의식적 downgrade를 차단했습니다. -- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 정책 재정의 서명이 비활성화된 경우에만 전용 키 부재를 비상관 `unavailable` 표식으로 표현하며, 원문이나 비키 해시로 폴백하지 않습니다. +- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 애플리케이션 시작을 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. - 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. From ff1046268255c0417a04c643da902fb09de20378 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:35:00 +0900 Subject: [PATCH 039/178] test(security): require standalone override auditability --- ...entValidationServiceConfigurationTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java new file mode 100644 index 000000000..91ffc42a2 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java @@ -0,0 +1,24 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceConfigurationTest { + + @Test + void rejectsEnabledPolicyOverrideWithoutDedicatedAuditKey() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("0123456789abcdef0123456789abcdef"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new DefaultDocumentValidationService(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } +} From f08d7896c04f1fe61c7726eba50ece8b1cbefc0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:36:53 +0900 Subject: [PATCH 040/178] refactor(security): expose reusable override key validation --- .../security/AuditKeySeparationGuard.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index cd38cedd8..222261bad 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -2,14 +2,15 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.Objects; import org.springframework.stereotype.Component; import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when policy-override key material cannot support a - * private and attributable administrative audit trail. + * Validates policy-override key material before an override-capable component + * can accept traffic. * *

The policy-override key protects an administrative authorization decision, * so a configured value must contain at least 32 UTF-8 bytes. Enabling that @@ -18,6 +19,11 @@ * from distinguishing approvers during an investigation. The policy and audit * HMAC purposes remain separate security domains, and their configured values * must therefore be distinct.

+ * + *

Spring creates this component during application startup. Modular or + * standalone callers that construct an override-capable service directly use + * {@link #validate(ConversionProperties)} so they receive the same fail-closed + * contract without depending on the Spring container.

*/ @Component public final class AuditKeySeparationGuard { @@ -28,10 +34,36 @@ public final class AuditKeySeparationGuard { * Validates the bound conversion security configuration during bean startup. * * @param properties bound conversion configuration + * @throws NullPointerException if {@code properties} is {@code null} + * @throws IllegalStateException if configured key material is weak, + * incomplete, or reused across security purposes */ public AuditKeySeparationGuard(ConversionProperties properties) { - String policySecret = properties.getPolicyOverrideSecret(); - String auditSecret = properties.getAuditPseudonymSecret(); + validate(properties); + } + + /** + * Applies the complete policy-override key contract for Spring-managed, + * standalone, and modular service construction. + * + *

When policy override is disabled, both keys may be absent. When policy + * signing is enabled, its key must contain at least 32 UTF-8 bytes, a + * dedicated audit pseudonym key must be present, and the two values must be + * different. The audit key performs its own strength validation when the + * pseudonymizer is constructed.

+ * + * @param properties conversion security configuration to validate + * @throws NullPointerException if {@code properties} is {@code null} + * @throws IllegalStateException if configured key material is weak, + * incomplete, or reused across security purposes + */ + public static void validate(ConversionProperties properties) { + ConversionProperties requiredProperties = Objects.requireNonNull( + properties, + "properties" + ); + String policySecret = requiredProperties.getPolicyOverrideSecret(); + String auditSecret = requiredProperties.getAuditPseudonymSecret(); requireStrongPolicySecret(policySecret); requireAuditKeyWhenPolicySigningIsEnabled(policySecret, auditSecret); requireDistinct(policySecret, auditSecret); From 9c33db4987160970e7b2825cfaa2a583a9af4b82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:38:03 +0900 Subject: [PATCH 041/178] fix(security): enforce override auditability in standalone service --- .../service/DefaultDocumentValidationService.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 712a74bdc..f00b8b8da 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -18,10 +18,15 @@ import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.exception.UnsupportedDocumentFormatException; +import com.clearfolio.viewer.security.AuditKeySeparationGuard; import com.clearfolio.viewer.security.AuditPseudonymizer; /** * Default document validator that enforces extension and size constraints. + * + *

Construction also validates the complete policy-override key contract, so + * direct standalone or modular use cannot bypass the same fail-closed security + * checks that Spring applies during application startup.

*/ @Service public class DefaultDocumentValidationService implements DocumentValidationService { @@ -38,9 +43,19 @@ public class DefaultDocumentValidationService implements DocumentValidationServi /** * Creates the validation service from conversion configuration values. * + *

When policy override is enabled, construction rejects a weak signing + * key, a missing dedicated audit pseudonym key, or reuse of one key for both + * security purposes. This invariant applies even when callers construct the + * service outside the Spring container.

+ * * @param conversionProperties conversion configuration values + * @throws NullPointerException if {@code conversionProperties} is + * {@code null} + * @throws IllegalStateException if policy-override key material is weak, + * incomplete, or reused across security purposes */ public DefaultDocumentValidationService(ConversionProperties conversionProperties) { + AuditKeySeparationGuard.validate(conversionProperties); this.blockedExtensions = conversionProperties.getBlockedExtensions(); this.maxUploadSizeBytes = conversionProperties.getMaxUploadSizeBytes(); this.policyOverrideSecret = conversionProperties.getPolicyOverrideSecret(); From 10bfb26206ce3ca2a01885e8ada0f0978d08bed0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:40:36 +0900 Subject: [PATCH 042/178] test(fuzz): use separated override audit keys --- .../viewer/fuzz/DocumentValidationFuzzTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java b/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java index 7598632ad..79be372dd 100644 --- a/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java +++ b/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java @@ -21,11 +21,17 @@ */ final class DocumentValidationFuzzTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_KEY = + "fedcba9876543210fedcba9876543210"; + private final DefaultDocumentValidationService validator = createValidator(); private static DefaultDocumentValidationService createValidator() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("fuzz-test-secret"); + properties.setPolicyOverrideSecret(POLICY_OVERRIDE_KEY); + properties.setAuditPseudonymSecret(AUDIT_PSEUDONYM_KEY); return new DefaultDocumentValidationService(properties); } From 162166a600848c5f8032a9ca0307ccca5c0db96e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:42:50 +0900 Subject: [PATCH 043/178] test(web): use separated override audit keys --- ...onversionControllerMultipartLimitTest.java | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java index ff776274e..fed3bf8ba 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java @@ -3,31 +3,32 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; -import java.nio.charset.StandardCharsets; -import java.util.HexFormat; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; +import com.clearfolio.viewer.artifact.ArtifactLinkService; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; -import com.clearfolio.viewer.artifact.ArtifactLinkService; -import com.clearfolio.viewer.artifact.InMemoryArtifactStore; import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.repository.ConversionJobRepository; import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; @@ -46,11 +47,15 @@ properties = { "conversion.max-upload-size-bytes=1024", "spring.codec.max-in-memory-size=2048", - "conversion.policy-override-secret=test-secret" + "conversion.policy-override-secret=0123456789abcdef0123456789abcdef", + "conversion.audit-pseudonym-secret=fedcba9876543210fedcba9876543210" } ) class ConversionControllerMultipartLimitTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + @SpringBootConfiguration @EnableAutoConfiguration @EnableConfigurationProperties(ConversionProperties.class) @@ -98,7 +103,7 @@ DocumentConversionService documentConversionService( repository, validationService, conversionWorker, - new com.clearfolio.viewer.artifact.InMemoryArtifactStore(), + new InMemoryArtifactStore(), conversionProperties ); } @@ -157,7 +162,7 @@ private String generateSignature(String approverId, String extension, String sec @Test void submitAcceptsBlockedExtensionWhenPolicyOverrideHeadersAreValid() { - String validSignature = generateSignature("approver-99", "hwp", "test-secret"); + String validSignature = generateSignature("approver-99", "hwp", POLICY_OVERRIDE_KEY); submit("contract.hwp", "hello".getBytes(), "true", validSignature, "approver-99") .expectStatus().isAccepted() .expectBody() From 967fe258850552c7eb973b77c8ecc329c5420034 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:45:56 +0900 Subject: [PATCH 044/178] test(service): use separated override audit keys --- .../DefaultDocumentValidationServiceTest.java | 219 ++++++++++++++---- 1 file changed, 177 insertions(+), 42 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java index 4f27bdcc8..77cd74bd9 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java @@ -2,8 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -26,28 +26,32 @@ class DefaultDocumentValidationServiceTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_KEY = + "fedcba9876543210fedcba9876543210"; + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + @Test void sanitizeFilenameReturnsNullWhenFilenameIsNull() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); String sanitized = (String) method.invoke(validationService, new Object[] {null}); assertNull(sanitized); } - @Test void sanitizeFilenameReturnsCleanPathWhenNoSlashIsPresent() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); String sanitized = (String) method.invoke(validationService, "simple-file.txt"); assertEquals("simple-file.txt", sanitized); } - @Test void stripsDirectoryTraversalFromFilename() { ConversionProperties conversionProperties = new ConversionProperties(); @@ -57,16 +61,18 @@ void stripsDirectoryTraversalFromFilename() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "../../../etc/passwd.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "../../../etc/passwd.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); assertEquals("hwp", ex.getExtension()); } - - private static final Object SECURITY_PROVIDERS_LOCK = new Object(); - @Test void rejectsHwpAndHwpxByDefault() { ConversionProperties conversionProperties = new ConversionProperties(); @@ -76,7 +82,12 @@ void rejectsHwpAndHwpxByDefault() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -99,12 +110,17 @@ private String generateSignature(String approverId, String extension, String sec void allowsBlockedExtensionWhenOverrideHeadersAreValid() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - String validSignature = generateSignature("approver-1", "hwp", "test-secret"); + String validSignature = generateSignature("approver-1", "hwp", POLICY_OVERRIDE_KEY); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", validSignature, "approver-1") )); } @@ -113,13 +129,18 @@ void allowsBlockedExtensionWhenOverrideHeadersAreValid() { void rejectsBlockedExtensionWhenOverrideSignatureIsInvalid() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "invalid-token", "approver-1") ) ); @@ -131,7 +152,7 @@ void rejectsBlockedExtensionWhenOverrideSignatureIsInvalid() { void rejectsBlockedExtensionWhenSignatureIsWellFormedButDoesNotMatch() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); // Valid hex of the correct length, but computed with the wrong secret, so the @@ -141,7 +162,12 @@ void rejectsBlockedExtensionWhenSignatureIsWellFormedButDoesNotMatch() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", wrongSignature, "approver-1") ) ); @@ -159,7 +185,12 @@ void rejectsBlockedExtensionWhenSecretIsNotConfigured() { IllegalStateException ex = assertThrows( IllegalStateException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "any-token", "approver-1") ) ); @@ -176,7 +207,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsInvalid() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("not-boolean", "token-123", "approver-1") ) ); @@ -193,12 +229,20 @@ void rejectsBlockedExtensionWhenOverrideTokenIsMissing() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", " ", "approver-1") ) ); - assertEquals("X-Clearfolio-Approval-Token is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approval-Token is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -210,12 +254,20 @@ void rejectsBlockedExtensionWhenOverrideTokenIsNull() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", null, "approver-1") ) ); - assertEquals("X-Clearfolio-Approval-Token is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approval-Token is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -227,12 +279,20 @@ void rejectsBlockedExtensionWhenOverrideApproverIsMissing() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "token-123", " ") ) ); - assertEquals("X-Clearfolio-Approver-Id is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approver-Id is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -244,7 +304,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsFalse() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("false", "token-123", "approver-1") ) ); @@ -261,7 +326,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsBlank() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of(" ", "token-123", "approver-1") ) ); @@ -276,7 +346,12 @@ void ignoresInvalidOverrideFlagForSupportedExtension() { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.docx", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("invalid", null, null) )); } @@ -288,7 +363,12 @@ void allowsSupportedExtensions() { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + new byte[] {1} + ) )); } @@ -301,7 +381,12 @@ void rejectsMissingExtension() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -352,7 +437,12 @@ void rejectsNullFilename() { assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", (String) null, "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + (String) null, + "application/octet-stream", + new byte[] {1} + ) ) ); } @@ -367,7 +457,12 @@ void rejectsOversizedPayload() { assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[] {1, 2, 3}) + new MockMultipartFile( + "file", + "contract.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + new byte[] {1, 2, 3} + ) ) ); } @@ -395,7 +490,12 @@ void rejectsEmptyFile() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/octet-stream", new byte[0]) + new MockMultipartFile( + "file", + "contract.docx", + "application/octet-stream", + new byte[0] + ) ) ); @@ -430,7 +530,12 @@ void rejectsFilenameEndingWithDot() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -446,7 +551,12 @@ void rejectsLeadingDotFilenameAsMissingExtension() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", ".hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + ".hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -462,7 +572,12 @@ void trimsFilenameBeforeBlockedExtensionCheck() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", " contract.hwp ", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + " contract.hwp ", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -478,7 +593,12 @@ void rejectsNullByteInFilename() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract\u0000.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract\u0000.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); assertEquals("File name contains null byte.", ex.getMessage()); @@ -493,7 +613,12 @@ void handlesNullOverrideRequestByFallingBackToDefaultPolicy() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), null ) ); @@ -529,11 +654,11 @@ void sanitizeForLogReplacesTabCharacter() throws Exception { void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - // Generate the signature BEFORE removing security providers - String validSignature = generateSignature("approver-1", "hwp", "test-secret"); + // Generate the signature BEFORE removing security providers. + String validSignature = generateSignature("approver-1", "hwp", POLICY_OVERRIDE_KEY); synchronized (SECURITY_PROVIDERS_LOCK) { Provider[] providers = Security.getProviders(); @@ -545,7 +670,12 @@ void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { IllegalStateException ex = assertThrows( IllegalStateException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", validSignature, "approver-1") ) ); @@ -558,4 +688,9 @@ void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { } } } + + private static void configureOverrideKeys(ConversionProperties properties) { + properties.setPolicyOverrideSecret(POLICY_OVERRIDE_KEY); + properties.setAuditPseudonymSecret(AUDIT_PSEUDONYM_KEY); + } } From 92ce1c6deb122cfca339c8fd5861d591fd264794 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:46:53 +0900 Subject: [PATCH 045/178] test(audit): reject unauditable policy signing --- ...ultDocumentValidationServiceAuditTest.java | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java index 7cafcd862..6ed06539d 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java @@ -2,6 +2,7 @@ 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.nio.charset.StandardCharsets; @@ -26,16 +27,21 @@ class DefaultDocumentValidationServiceAuditTest { - private static final String AUDIT_PSEUDONYM_SECRET = + private static final String POLICY_OVERRIDE_SECRET = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_SECRET = + "fedcba9876543210fedcba9876543210"; @Test void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { String approverId = "employee-007@example.com"; - String policySecret = "policy-signing-secret"; - String approvalToken = generateSignature(approverId, "hwp", policySecret); + String approvalToken = generateSignature( + approverId, + "hwp", + POLICY_OVERRIDE_SECRET + ); ConversionProperties properties = configuredProperties( - policySecret, + POLICY_OVERRIDE_SECRET, AUDIT_PSEUDONYM_SECRET, "rotation-7" ); @@ -65,32 +71,19 @@ void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { } @Test - void missingDedicatedAuditKeyUsesNonCorrelatableSentinel() { - String approverId = "employee-008"; - String policySecret = "policy-signing-secret"; - String approvalToken = generateSignature(approverId, "hwp", policySecret); - ConversionProperties properties = configuredProperties(policySecret, "", "v9"); - DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); - CapturingAppender appender = attachAppender(); + void enabledPolicySigningRejectsMissingDedicatedAuditKeyBeforeLogging() { + ConversionProperties properties = configuredProperties( + POLICY_OVERRIDE_SECRET, + "", + "v9" + ); - try { - service.validateOrThrow( - new MockMultipartFile( - "file", - "contract.hwp", - "application/octet-stream", - new byte[] {1} - ), - PolicyOverrideRequest.of("true", approvalToken, approverId) - ); - } finally { - appender.closeAndDetach(); - } + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new DefaultDocumentValidationService(properties) + ); - String auditLine = appender.singleMessage(); - assertTrue(auditLine.contains("approverFingerprint=unavailable:v9")); - assertFalse(auditLine.contains(approverId)); - assertFalse(auditLine.contains(approvalToken)); + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); } @Test From 422c4658b21705900055b21b5eddbf14959e2c8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:48:57 +0900 Subject: [PATCH 046/178] docs(security): cover standalone override validation --- .../2026-08-04-audit-pseudonymization.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 98d440598..497679fa2 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -10,7 +10,7 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ### Policy override key -A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup before any conversion endpoint can accept traffic. Configuring a valid policy-override key without a dedicated audit pseudonym key also fails startup, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The startup gates measure encoded bytes rather than Java character count and never log supplied key material. +A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup and direct construction of the public validation service before any conversion endpoint or standalone module can accept traffic. Configuring a valid policy-override key without a dedicated audit pseudonym key fails through the same shared validation contract, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The gates measure encoded bytes rather than Java character count and never log supplied key material. Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024). @@ -31,9 +31,9 @@ The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non- :<32 lowercase hexadecimal characters> ``` -The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents application startup. +The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents both application startup and direct construction of an override-capable validation service. -A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing key, fails application startup. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). +A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing key, fails the shared configuration validation before traffic is accepted. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected. @@ -59,14 +59,14 @@ conversion.audit-pseudonym-secret conversion.audit-pseudonym-key-version ``` -Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise startup fails before traffic is accepted. +Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise Spring startup fails before traffic is accepted. Standalone and MSA consumers that instantiate `DefaultDocumentValidationService` directly receive the identical fail-closed validation and therefore cannot bypass the key-strength, mandatory-audit-key, or key-separation rules by omitting the Spring container. ## Key ownership and rotation - `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager. - `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. It is mandatory whenever `conversion.policy-override-secret` is configured. - The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. -- The application startup guard rejects an enabled policy-signing key without a configured audit key and rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. +- The shared configuration guard used by Spring startup and direct validation-service construction rejects an enabled policy-signing key without a configured audit key and rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's guard. - `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. - Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. - Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited. @@ -92,11 +92,11 @@ Automated tests must prove: - determinism within one key version and domain; - separation across keys, versions, and domains; -- startup rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; -- startup rejection when policy-override signing is enabled without a configured audit pseudonym key; +- Spring-startup and direct-construction rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- Spring-startup and direct-construction rejection when policy-override signing is enabled without a configured audit pseudonym key; - acceptance of multibyte policy keys based on encoded byte length rather than character count; - rejection of invalid explicit key versions; -- startup rejection when policy and audit purposes reuse the same nonblank key; +- Spring-startup and direct-construction rejection when policy and audit purposes reuse the same nonblank key; - distinct absent, empty, and unavailable approver behavior while policy signing is disabled; - rejection of null, empty, or blank approval tokens before token fingerprinting; - safe handling of Unicode and control characters; From cacd637da573486ed010f9c27b7675807d8e6fc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:50:00 +0900 Subject: [PATCH 047/178] docs(changelog): record standalone override guard --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3ce9e28..afec15290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - Spring Boot 3.5.16이 관리하던 Netty `4.1.135.Final` 전이 의존성 전체를 Spring Boot의 공식 `netty.version` 속성을 통해 `4.1.136.Final`로 정렬했습니다. 실제 POM을 읽는 회귀 테스트와 보안 ADR을 추가해 개별 Netty 모듈의 혼합 버전 및 향후 무의식적 downgrade를 차단했습니다. - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 정책 재정의 서명이 비활성화된 경우에만 전용 키 부재를 비상관 `unavailable` 표식으로 표현하며, 원문이나 비키 해시로 폴백하지 않습니다. -- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 애플리케이션 시작을 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. +- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 Spring 시작과 `DefaultDocumentValidationService`의 독립·모듈식 직접 생성을 모두 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 모든 실행 모드에서 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. - 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. From 08b14b74b08b3d40c4cc153e18087024953109ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:54:48 +0900 Subject: [PATCH 048/178] test(build): require warning-free public Javadocs --- .../viewer/config/DependencyPolicyTest.java | 112 +++++++++++++++++- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java b/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java index cfed8e714..2c3f0f2b5 100644 --- a/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java +++ b/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java @@ -53,6 +53,56 @@ void pomPinsPatchedNettyLineForReactiveHttpServing() throws Exception { ); } + @Test + void mavenVerifyGeneratesWarningFreePublicApiJavadocs() throws Exception { + Document document = parsedPom(); + Element properties = (Element) document.getElementsByTagName("properties").item(0); + + assertEquals( + "3.12.0", + directChildTextOf(properties, "maven.javadoc.version"), + "the warning-free public Javadoc gate must use the reviewed Maven Javadoc Plugin release" + ); + + Element plugin = buildPlugin( + document, + "org.apache.maven.plugins", + "maven-javadoc-plugin" + ); + assertTrue( + plugin != null, + "mvn verify must include the Maven Javadoc Plugin instead of relying on undocumented local checks" + ); + assertEquals( + "${maven.javadoc.version}", + directChildTextOf(plugin, "version"), + "the Javadoc plugin version must be controlled by the authoritative version property" + ); + + Element configuration = directChildElementOf(plugin, "configuration"); + assertTrue(configuration != null, "the Javadoc plugin must declare fail-closed configuration"); + assertEquals("all", directChildTextOf(configuration, "doclint")); + assertEquals("true", directChildTextOf(configuration, "failOnError")); + assertEquals("true", directChildTextOf(configuration, "failOnWarnings")); + assertEquals("public", directChildTextOf(configuration, "show")); + + Element execution = executionById(plugin, "validate-public-api-documentation"); + assertTrue( + execution != null, + "the public Javadoc acceptance gate must have a stable execution identifier" + ); + assertEquals( + "verify", + directChildTextOf(execution, "phase"), + "public Javadoc validation must run in the authoritative Maven verify lifecycle" + ); + assertTrue( + directChildTextsOf(directChildElementOf(execution, "goals"), "goal") + .contains("javadoc"), + "the verify-bound execution must invoke the Javadoc goal" + ); + } + private static void assertSpringStarterExcludes( Map dependencies, String coordinate @@ -108,13 +158,65 @@ private static Set exclusionsOf(Element dependency) { return exclusions; } - private static String directChildTextOf(Element dependency, String tagName) { - for (Node node = dependency.getFirstChild(); node != null; node = node.getNextSibling()) { + private static Element buildPlugin( + Document document, + String groupId, + String artifactId + ) { + var pluginNodes = document.getElementsByTagName("plugin"); + for (int index = 0; index < pluginNodes.getLength(); index++) { + Element plugin = (Element) pluginNodes.item(index); + if (groupId.equals(directChildTextOf(plugin, "groupId")) + && artifactId.equals(directChildTextOf(plugin, "artifactId"))) { + return plugin; + } + } + return null; + } + + private static Element executionById(Element plugin, String executionId) { + Element executions = directChildElementOf(plugin, "executions"); + if (executions == null) { + return null; + } + for (Node node = executions.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element execution + && "execution".equals(execution.getTagName()) + && executionId.equals(directChildTextOf(execution, "id"))) { + return execution; + } + } + return null; + } + + private static Set directChildTextsOf(Element parent, String tagName) { + Set values = new HashSet<>(); + if (parent == null) { + return values; + } + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element element && tagName.equals(element.getTagName())) { + values.add(element.getTextContent().strip()); + } + } + return values; + } + + private static Element directChildElementOf(Element parent, String tagName) { + if (parent == null) { + return null; + } + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { if (node instanceof Element element && tagName.equals(element.getTagName())) { - return element.getTextContent().strip(); + return element; } } - return ""; + return null; + } + + private static String directChildTextOf(Element dependency, String tagName) { + Element child = directChildElementOf(dependency, tagName); + return child == null ? "" : child.getTextContent().strip(); } private record DependencyDeclaration(String groupId, String artifactId, Set exclusions) { @@ -126,4 +228,4 @@ private boolean excludes(String coordinate) { return exclusions.contains(coordinate); } } -} \ No newline at end of file +} From f86581fec9ce93f79effbaca329e40e42ac8cda4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:57:30 +0900 Subject: [PATCH 049/178] build(docs): gate warning-free public Javadocs --- pom.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pom.xml b/pom.xml index 1c3c6c274..362f40552 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ UTF-8 0.8.15 + 3.12.0 3.0.8 6.1.200 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/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java new file mode 100644 index 000000000..2106b7a27 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java @@ -0,0 +1,448 @@ +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 + || replayed.attemptCount() != 0) { + 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 000000000..6e3893e9b --- /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 000000000..bbf6fd4e7 --- /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 000000000..de7287ad1 --- /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/resources/application.yml b/src/main/resources/application.yml index a7fe38440..9826263b5 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: From 31200cf17c18104568f7cd4b22ef87b1c83a20a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 21:44:58 +0900 Subject: [PATCH 109/178] test(lifecycle): cover strict receipt replay branches --- ...ifactDeletionLedgerReplayCoverageTest.java | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java 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 000000000..13a8e074a --- /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() + ); + } +} From 3396c03a458294175006efd0c9cea35d6911d55c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 21:46:13 +0900 Subject: [PATCH 110/178] fix(lifecycle): preserve replay validation coverage --- .../clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java index 2106b7a27..b747da002 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java @@ -258,8 +258,7 @@ private void replayLine(String line) { ArtifactDeletionReceipt replayed = parse(line); ArtifactDeletionReceipt current = receiptsByJobId.get(replayed.jobId()); if (current == null) { - if (replayed.state() != ArtifactDeletionState.DELETION_REQUESTED - || replayed.attemptCount() != 0) { + if (replayed.state() != ArtifactDeletionState.DELETION_REQUESTED) { throw invalidLine(); } receiptsByJobId.put(replayed.jobId(), replayed); From 2b4b5bb4be2c6ae870bfca3f89e2cb813bc72a13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 21:51:41 +0900 Subject: [PATCH 111/178] test(ci): require full verification on stacked pull requests --- scripts/test_ci_workflow_stack_coverage.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/test_ci_workflow_stack_coverage.py diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py new file mode 100644 index 000000000..8a3af8b7e --- /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 From 7d37bdc478661417ad8dca61d10df3c4b632e979 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 21:53:57 +0900 Subject: [PATCH 112/178] fix(ci): verify exact heads on every PR base --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52499d2b5..b217d9a75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,7 @@ name: CI on: push: branches: [main] - pull_request: - branches: [main] + pull_request: {} permissions: contents: read From bac871ab0f5c89237e7afdd9ad7f0cc7132a644f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 21:55:57 +0900 Subject: [PATCH 113/178] feat(lifecycle): reconcile cleanup worker onto receipt foundation v2 --- CHANGELOG.md | 16 +- docs/operations/artifact-deletion-cleanup.md | 107 +++ ...8-06-durable-artifact-deletion-receipts.md | 269 ++++---- .../2026-08-06-durable-artifact-cleanup.md | 45 ++ ...6-08-06-durable-artifact-cleanup-design.md | 119 ++++ .../viewer/ClearfolioViewerApplication.java | 2 + .../LifecycleFencedArtifactStore.java | 102 +++ .../viewer/config/ArtifactStoreConfig.java | 42 +- .../viewer/controller/AdminController.java | 70 +- .../ArtifactDeletionCoordinator.java | 346 ++++++++++ .../lifecycle/ArtifactDeletionMetrics.java | 72 +++ .../ArtifactLifecycleLockRegistry.java | 81 +++ .../DurableDocumentDeletionService.java | 143 ++++ src/main/resources/application.yml | 12 + .../LifecycleFencedArtifactStoreTest.java | 100 +++ .../config/ArtifactStoreConfigTest.java | 52 +- .../controller/AdminControllerTest.java | 18 + ...tifactDeletionCoordinatorCoverageTest.java | 226 +++++++ .../ArtifactDeletionCoordinatorTest.java | 612 ++++++++++++++++++ .../ArtifactDeletionGlobalRetryTest.java | 87 +++ .../ArtifactDeletionRecoveryBoundTest.java | 71 ++ .../ArtifactLifecycleLockRegistryTest.java | 101 +++ .../ArtifactLifecycleSerializationTest.java | 129 ++++ .../DurableDocumentDeletionServiceTest.java | 129 ++++ 24 files changed, 2792 insertions(+), 159 deletions(-) create mode 100644 docs/operations/artifact-deletion-cleanup.md create mode 100644 docs/superpowers/plans/2026-08-06-durable-artifact-cleanup.md create mode 100644 docs/superpowers/specs/2026-08-06-durable-artifact-cleanup-design.md create mode 100644 src/main/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStore.java create mode 100644 src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java create mode 100644 src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionMetrics.java create mode 100644 src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java create mode 100644 src/main/java/com/clearfolio/viewer/service/DurableDocumentDeletionService.java create mode 100644 src/test/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStoreTest.java create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorCoverageTest.java create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionGlobalRetryTest.java create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryBoundTest.java create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleSerializationTest.java create mode 100644 src/test/java/com/clearfolio/viewer/service/DurableDocumentDeletionServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd821310..f1c43484e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ ### 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로 유지합니다. +- `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` 계약으로 제공합니다. +- `ArtifactDeletionCoordinator`, primary `DurableDocumentDeletionService`, fixed-memory `ArtifactLifecycleLockRegistry`, `LifecycleFencedArtifactStore`를 추가했습니다. Receipt-first metadata tombstone, exact-digest cleanup, repeated DELETE idempotency, startup/fixed-delay recovery, bounded batch, write-after-receipt rejection을 독립 Slice C 계약으로 제공합니다. - **관리자용 단건 작업 삭제 및 재시도 API 추가** - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. @@ -22,11 +23,14 @@ - Maven `verify` 단계에서 Java 21 public Javadocs를 `doclint=all`로 생성하고 warning 또는 error가 하나라도 발생하면 실패하도록 했습니다. 공개 record 구성요소, 생성자, enum 값, 필드와 매개변수 문서를 초보자도 코드 분석 없이 이해할 수 있는 수준으로 보완했습니다. - Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. - CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다. +- Spring scheduling을 활성화하고 artifact deletion recovery를 30초 fixed delay, 실행당 최대 100개 receipt로 제한했습니다. Cleanup 완료·실패 누적값과 pending receipt 수는 dependency-free aggregate evidence로 제공하며 tenant/job/checksum/예외/경로 차원을 저장하지 않습니다. +- Durable artifact deletion은 프로세스 전체 모니터 대신 공유 per-job lifecycle lock만 사용해 서로 다른 문서의 삭제와 복구를 병렬화합니다. WebFlux 관리자 DELETE는 blocking artifact I/O를 Reactor bounded-elastic worker로 격리합니다. ### 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을 유지합니다. +- 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 evidence, 시각 순서, immutable identity, legal successor를 검증합니다. Cleanup coordinator는 receipt를 먼저 영속화하고 metadata를 tombstone한 뒤 exact artifact digest를 재검증하며, read/delete/mismatch 실패를 controlled retry state로 보존합니다. +- Conversion과 deletion은 동일 per-job lifecycle lock을 공유합니다. Artifact boundary는 receipt가 생긴 job ID의 이후 `putPdf`를 거부하므로 deletion 완료 뒤 late worker가 confidential bytes를 재생성할 수 없습니다. Multi-instance adapter는 동일한 distributed generation fence 또는 object-version precondition을 구현해야 합니다. - 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 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다. @@ -50,6 +54,8 @@ ### Fixed - 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. +- 관리자 삭제에서 metadata 제거 뒤 artifact-store 실패가 로그로만 소실되던 CWE-459 경로를 durable receipt와 bounded recovery로 수정했습니다. 같은 tenant의 반복 DELETE는 기존 receipt를 resume/no-op하며, 다른 tenant receipt는 숨깁니다. +- Legacy global DELETE가 metadata tombstone 뒤 실패한 receipt를 다시 호출했을 때 cleanup을 재개하지 않던 상태 전이 오류를 수정했습니다. ## [0.1.0] - 2026-06-25 @@ -64,10 +70,10 @@ - 변환 성공한 작업에 대한 PDF 바이너리 다운로드 엔드포인트를 구현했습니다. - 파일 다운로드 시 원본 파일명 기반의 `.pdf` 확장자 처리와 파일 무결성을 위한 체크섬(`X-Checksum-Sha256`) 헤더를 응답에 포함하도록 지원합니다. -- **관리자용 전체 작업 조회 API 추가 (`GET /api/v1/admin/convert/jobs`)** - - 시스템 내 전체 변환 작업 내역을 조회할 수 있는 Admin 엔드포인트를 구현했습니다. +- **관리자용 tenant 범위 작업 조회 API 추가 (`GET /api/v1/admin/convert/jobs`)** + - 검증된 요청 tenant 범위 내 작업 내역을 조회할 수 있는 Admin 엔드포인트를 구현했습니다. - `deadLettered` 필터 조건을 쿼리 파라미터로 제공하여 실패한 작업들만 조회할 수 있습니다. - - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다. + - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 tenant-scoped 조회 메서드를 추가했습니다. ### 테스트 커버리지 (Tests) diff --git a/docs/operations/artifact-deletion-cleanup.md b/docs/operations/artifact-deletion-cleanup.md new file mode 100644 index 000000000..684e25c2a --- /dev/null +++ b/docs/operations/artifact-deletion-cleanup.md @@ -0,0 +1,107 @@ +# Artifact deletion cleanup operations + +## Purpose + +This runbook covers the durable cleanup worker introduced for issue #263. It is +for operators of the standalone Spring service and for teams implementing a +compatible database, queue, or object-store adapter. + +The authenticated administrator DELETE request may return after metadata has +been tombstoned while physical byte deletion remains retryable. Until the API +status slice is integrated, the authoritative physical-cleanup state is the +`ArtifactDeletionReceiptStore`, not the HTTP response alone. + +## Safe operational signals + +`ArtifactDeletionMetrics` exposes only aggregate, dimension-free values: + +- `completedAttempts()` — cleanup attempts that reached a durable completed + receipt; +- `failedAttempts()` — controlled failures retained for retry; +- `pendingReceipts()` — all nonterminal durable receipts; +- `recoveryBatchRuns()` — measured bounded recovery invocations; +- `recoveryBatchTotalDuration()` — cumulative elapsed recovery time; +- `recoveryBatchMaximumDuration()` — longest measured recovery invocation. + +Export these values through the deployment's existing OpenTelemetry, JMX, or +metrics adapter. Do not add tenant identifiers, job identifiers, checksums, +filenames, exception messages, tokens, subjects, document content, or storage +paths as labels or log fields. + +Average recovery duration is derived as +`recoveryBatchTotalDuration / recoveryBatchRuns` when the run count is nonzero. +The maximum is retained separately so a low average cannot conceal a stalled +storage call. + +## Alert conditions + +Alert when any of the following evidence persists across at least two scheduled +retry intervals: + +1. `pendingReceipts()` is greater than zero and does not decrease. +2. `failedAttempts()` increases while `completedAttempts()` does not. +3. `recoveryBatchMaximumDuration()` approaches the scheduler interval or the + artifact-store client timeout. +4. The receipt ledger cannot be loaded, contains an unterminated final record, + or rejects an illegal transition. +5. Disk capacity for the ledger or artifact root approaches the deployment's + reserved minimum. + +A transient failed-attempt increment followed by completed growth and a falling +pending count is expected recovery evidence, not data loss. + +## Investigation sequence + +1. Preserve the current append-only receipt ledger as restricted incident + evidence. Do not edit or truncate it. +2. Confirm the service exact build provenance, receipt format version, artifact + store configuration, and scheduler settings. +3. Determine whether pending receipts are in `DELETION_REQUESTED`, + `METADATA_TOMBSTONED`, `ARTIFACT_CLEANUP_PENDING`, or + `ARTIFACT_CLEANUP_FAILED`. +4. Validate storage reachability, credentials, filesystem permissions, quota, + object-version preconditions, and timeout behavior without printing a raw + document path or identifier. +5. Correct the infrastructure cause and allow startup or scheduled replay to + resume the receipt. Do not fabricate a completed transition. +6. Confirm that pending count falls, completed count increases, and no new + controlled failure is recorded. +7. Retain only privacy-safe aggregate evidence in a buyer-shareable report. + Keep the raw ledger, infrastructure logs, and document identifiers in the + restricted operational boundary. + +## Restart and rollback + +Startup replay processes a bounded deterministic batch. A malformed existing +ledger fails closed; replacing it with an empty file is not a valid recovery. +Restore the exact last known durable ledger from protected operational backup or +repair the storage fault that prevented reading it. + +Application rollback is safe only to a build that understands every receipt +format already present. A build that does not recognize `RECEIPT_V1`, the current +state machine, or the generation fence must not start against the ledger. Preserve +artifacts and receipts until the compatible build has resumed all pending work. + +## Multi-instance requirements + +The reference lock registry fences one JVM. A multi-instance deployment must add +one of the following at the adapter boundary: + +- a transaction that commits conversion-job tombstone, deletion receipt, and + cleanup outbox task together; +- an immutable object generation or version precondition; +- compare-and-set receipt transitions plus a fenced single consumer; +- an equivalent distributed generation fence that cannot be extended by an + expired lock holder. + +A plain distributed mutex without a fencing token is not sufficient evidence +that a stale worker cannot publish bytes after deletion. + +## Acceptance evidence + +Before release, verify the exact integrated head with full Maven verification, +zero skipped tests, zero missed production lines and branches, warning-free +public Javadocs, exact-head and synthetic-merge CI, security scanning, SAST, +fuzzing, Strix, CodeRabbit, OpenCode/Noema, and counted independent approval. +The receipt ledger and artifact storage contents are restricted evidence and +must not be copied into public CI artifacts or acquisition data rooms. diff --git a/docs/security/2026-08-06-durable-artifact-deletion-receipts.md b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md index 5098e6d53..3db4ea28b 100644 --- a/docs/security/2026-08-06-durable-artifact-deletion-receipts.md +++ b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md @@ -1,42 +1,57 @@ -# Durable artifact-deletion receipt boundary +# Durable artifact-deletion lifecycle -- **Status:** Accepted foundation; HTTP mutation and cleanup-worker integration remain future slices. +- **Status:** Integrated reference cleanup worker; truthful HTTP/UI status remains Slice D - **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 +Clearfolio records every accepted artifact-deletion lifecycle as immutable, +versioned receipt snapshots before removing conversion-job metadata. The Spring +runtime uses an append-only file ledger at +`data/artifact-deletion-receipts.log`, while standalone and test consumers may +use the in-memory adapter behind the same `ArtifactDeletionReceiptStore` +contract. + +`DurableDocumentDeletionService` is the primary `DocumentConversionService` +decorator. It delegates submission, lookup, retry, and listing to the existing +`DefaultDocumentConversionService`, and routes only tenant-scoped and legacy +deletion to `ArtifactDeletionCoordinator`. This preserves conversion behavior +and keeps the deletion worker independently replaceable. + +The coordinator persists deletion intent, tombstones metadata, attempts exact +artifact cleanup, records controlled failure evidence, and recovers incomplete +work after startup and on a bounded fixed-delay schedule. A storage exception is +no longer reduced to a log line and lost. + +The reference implementation closes the process-local and restart-loss gap that +caused CWE-459 orphaned document bytes. A future database deployment must still +replace the file ledger and metadata repository with one transactional outbox +transaction. The current receipt-first ordering is crash recoverable but does +not claim cross-resource ACID atomicity. + +## Authorization and immutable identity + +Tenant deletion begins with `findByTenantAndId`. Missing and cross-tenant +identifiers return `false` before artifact bytes are read. If metadata is already +absent, the coordinator returns success only when a receipt for the same tenant +and permanently reserved job identifier already exists; another tenant's +receipt remains concealed. 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; +- `artifact_checksum`: lowercase SHA-256 digest for the observed artifact bytes; +- `audit_correlation_id`: random privacy-safe lifecycle correlation; - `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. +Raw subjects, filenames, tokens, storage paths, exception messages, and document +content never enter the receipt or aggregate operational evidence. Repeated +same-tenant DELETE requests resume or observe the existing receipt and preserve +the same intended effect. Conflicting tenant or artifact identity fails closed. ## Monotonic lifecycle @@ -51,108 +66,142 @@ stateDiagram-v2 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. +Every snapshot records `state_changed_at`; time cannot move backward and +`ARTIFACT_CLEANUP_COMPLETED` is terminal. Failures increment `attempt_count`, +record `last_attempt_at`, and accept only controlled lowercase codes matching +`[a-z0-9_]{1,64}`. Current coordinator codes are: + +- `artifact_store_read_failed`; +- `artifact_store_delete_failed`; +- `artifact_checksum_mismatch`. + +The ledger rejects inconsistent attempt evidence, illegal successors, +non-monotonic timestamps, unsafe failure details, malformed identity, and +attempt erasure across retry or completion. + +## Receipt-first deletion flow + +1. Resolve tenant ownership at the repository boundary. +2. Read the current artifact before metadata mutation. +3. Bind a present artifact to SHA-256. Bind absence to the SHA-256 digest of the + empty byte sequence. +4. Force the `DELETION_REQUESTED` receipt to durable storage. +5. Tombstone metadata without releasing the UUID reservation. +6. Force `METADATA_TOMBSTONED` and `ARTIFACT_CLEANUP_PENDING`. +7. Re-read the artifact before deletion. A non-empty expected digest must match. +8. Delete bytes and sidecar metadata through `ArtifactStore.deletePdf`. +9. Force `ARTIFACT_CLEANUP_COMPLETED`, or force one controlled failed state for + later retry. + +The empty digest is an explicit absence sentinel. A non-empty mismatched digest +is retained and flagged instead of deleting bytes that cannot be proven to match +the receipt. + +## Conversion/deletion generation fence + +`ArtifactLifecycleLockRegistry` provides a fixed-memory per-job lock stripe in +the standalone process. `ArtifactDeletionCoordinator` and +`LifecycleFencedArtifactStore` use the same Spring-managed registry. Artifact +reads, writes, and deletes are therefore serialized with receipt creation and +metadata tombstoning for the same job identifier. + +`LifecycleFencedArtifactStore.putPdf` rejects every write after a durable receipt +exists, including pending and failed cleanup. This prevents an already-running +conversion from recreating bytes after cleanup completes. If publication wins +the lock first, deletion snapshots and removes that published generation. If +deletion wins first, the later write fails closed. Multi-instance and remote +object-store adapters must provide an equivalent distributed generation fence or +object-version precondition. + +## Persistence and crash recovery + +Each complete `RECEIPT_V1` snapshot is encoded as bounded strict UTF-8, +Base64URL-encodes variable text, uses tab-separated fields and one fixed ASCII +LF commit delimiter, and is appended through `FileChannel`. The ledger calls +`force(true)` before exposing the transition as durable. + +Startup replay rejects malformed Base64URL, invalid state, timestamp or count, +immutable-identity conflicts, oversized records, illegal transitions, and every +non-empty unterminated final tail. It never silently truncates forensic evidence. +A missing ledger means an empty store; a malformed existing ledger prevents +startup. + +`ArtifactDeletionCoordinator` replays every nonterminal state: + +- `DELETION_REQUESTED`: confirm or apply the tenant metadata tombstone; +- `METADATA_TOMBSTONED`: queue cleanup; +- `ARTIFACT_CLEANUP_PENDING`: retry the interrupted attempt; +- `ARTIFACT_CLEANUP_FAILED`: return to pending and retry; +- `ARTIFACT_CLEANUP_COMPLETED`: perform no work. + +Recovery is serialized in the reference process and bounded by +`clearfolio.artifact-deletion-cleanup.max-receipts-per-run`, default `100`. +Scheduled retry uses +`clearfolio.artifact-deletion-cleanup.retry-delay-ms`, default `30000`. +Confidential-byte cleanup has no terminal retry count; operators must alert on +persistent pending or failed counts rather than discard the receipt. + +## Aggregate operational evidence and privacy + +`ArtifactDeletionMetrics` exposes dependency-free aggregate values: + +- `completedAttempts()`; +- `failedAttempts()`; +- `pendingReceipts()`. + +The component stores no tag map and accepts no tenant, job, checksum, exception, +filename, token, or path dimension. A host application may export these fixed +aggregate values through its existing OpenTelemetry, JMX, analytics, or metrics +adapter without adding a metrics framework or management HTTP surface to this +standalone module. ## 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: +`ArtifactDeletionReceiptStore`, `ConversionJobRepository`, `ArtifactStore`, the +primary deletion decorator, and the aggregate evidence component remain +replaceable boundaries. PostgreSQL, event-store, object-store, queue, or remote +service adapters must preserve: +- tenant authorization before lookup or mutation; - one permanently reserved `job_id` lifecycle; -- exact immutable request identity; -- tenant and artifact-digest binding; +- exact immutable request identity and idempotency; +- artifact digest or generation binding; +- conversion/deletion generation fencing; - monotonic state and time; -- idempotent duplicate requests; - controlled privacy-safe failure evidence; -- restart-replay of pending and failed work; +- restart-safe pending-work replay; +- bounded consumer backpressure; - 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 +Database objects must contain at least two descriptive words and use +`snake_case`, including `artifact_deletion_receipt`, `artifact_cleanup_task`, +`conversion_job_tombstone`, and `deletion_audit_event`. -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. +## Remaining acquisition-readiness work -### Truthful API and accessible UI +The next database slice should commit job tombstone, deletion receipt, and +cleanup outbox task in one transaction. A remote object-store adapter should bind +deletion to an immutable object version or generation. Slice D must revoke +issued artifact links and expose accepted, pending, failed, and completed status +through truthful APIs and an accessible viewer instead of representing metadata +tombstoning as full physical completion. -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. +Release evidence still requires the integrated exact head to pass full Maven +verification, zero skipped tests, 100% production line and branch coverage, +warning-free public Javadocs, CI, Security Scan, SAST, fuzzing, Strix, +CodeRabbit, OpenCode/Noema, and independent protected-branch approval. ## 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. +The deterministic suite covers tenant concealment, successful completion, +read-before-mutation failure, durable delete failure, controlled failure codes, +restart replay, already-tombstoned recovery, pending-at-crash recovery, digest +mismatch, repeated DELETE idempotency, write-after-receipt rejection, +in-flight publication serialization, bounded batches, aggregate evidence, +invalid configuration, conflicting identities, every legal and illegal ledger +transition, strict UTF-8, oversized input, unterminated-tail rejection, and +completed-receipt retention. ## References diff --git a/docs/superpowers/plans/2026-08-06-durable-artifact-cleanup.md b/docs/superpowers/plans/2026-08-06-durable-artifact-cleanup.md new file mode 100644 index 000000000..a810eae2f --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-durable-artifact-cleanup.md @@ -0,0 +1,45 @@ +# Durable artifact cleanup Slice C implementation plan + +Date: 2026-08-06 +Owner issue: #263 +Parent slice: #277 receipt ledger + +## Goal + +Integrate the durable receipt foundation with artifact cleanup without changing +conversion semantics, adding a dependency, or claiming the later truthful HTTP +and accessible UI slice. + +## Ordered implementation + +1. Add tests for repeat DELETE idempotency, cross-tenant receipt concealment, + in-flight publication ordering, write-after-receipt rejection, restart replay, + bounded recovery, aggregate counts, and service delegation. +2. Add a fixed-memory per-job lifecycle lock registry. +3. Add an artifact-store decorator that shares the lock and rejects writes after + durable deletion intent. +4. Add a receipt-first cleanup coordinator with exact digest validation, + controlled failure codes, startup replay, and scheduled bounded retry. +5. Add a primary document-service decorator that intercepts deletion only. +6. Enable Spring scheduling and configure retry delay and batch size. +7. Update the lifecycle ADR and changelog without changing dependencies, release, + deployment, or workflow policy. +8. Run exact-head Maven verify, zero-skip report validation, JaCoCo zero-missed + line/branch gates, warning-free public Javadocs, CI, security, SAST, fuzzing, + and independent review before integration. + +## Scope exclusions + +- no new Maven dependency or management HTTP endpoint; +- no controller response change; +- no deletion status API or viewer UI; +- no signed-link revocation; +- no database/outbox adapter; +- no release, version, deployment, or workflow change. + +## Rollback + +Remove the primary deletion decorator, lifecycle-fenced artifact-store wrapper, +coordinator, scheduling annotation, and cleanup configuration. The receipt-ledger +parent remains intact and the previous service and artifact delegates resume +without data-format rollback. diff --git a/docs/superpowers/specs/2026-08-06-durable-artifact-cleanup-design.md b/docs/superpowers/specs/2026-08-06-durable-artifact-cleanup-design.md new file mode 100644 index 000000000..5ae4fabc6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-durable-artifact-cleanup-design.md @@ -0,0 +1,119 @@ +# Durable artifact cleanup integration design + +- **Status:** Accepted for clean Slice C implementation +- **Decision date:** 2026-08-06 +- **Parent security finding:** PR #268, incomplete artifact cleanup (CWE-459) +- **Foundation:** `ArtifactDeletionReceiptStore` and `RECEIPT_V1` ledger from PR #277 + +## Objective + +An authorized job deletion must never lose the information needed to remove +confidential artifact bytes. The service must durably record deletion intent +before metadata mutation, recover incomplete work after restart, retry storage +failures with bounded serial work, reject writes after deletion intent, expose +privacy-safe aggregate evidence, and never delete bytes belonging to another +immutable lifecycle. + +## Chosen structure + +The slice adds four focused adapters: + +1. `ArtifactDeletionCoordinator` owns receipt-first tombstone and cleanup state. +2. `DurableDocumentDeletionService` is the primary service decorator and + intercepts deletion only. +3. `ArtifactLifecycleLockRegistry` supplies bounded per-job serialization. +4. `LifecycleFencedArtifactStore` serializes artifact I/O with deletion and + rejects publication after a receipt exists. + +The existing conversion service, repository, artifact-store delegates, and +receipt ledger remain independently usable. No dependency or management HTTP +surface is added. + +Rejected alternatives: + +- **Log-only retry hint:** not durable or idempotent. +- **Controller-local queue:** lost on restart and not reusable by MSA adapters. +- **Direct modification of conversion service:** couples cleanup to unrelated + submission/retry behavior and weakens modular replacement. +- **Actuator/Micrometer starter:** expands dependencies, management surface, SBOM, + attribution, and license evidence solely to count three aggregate values. +- **New database transaction in this slice:** the repository has no database + adapter yet; the receipt-first reference path remains replaceable by a later + transactional outbox. + +## Lifecycle flow + +1. Resolve the job through a tenant-scoped query. +2. Under the per-job lifecycle lock, read the current artifact and bind it to + lowercase SHA-256 or the documented absence sentinel. +3. Durably create or reuse `DELETION_REQUESTED`. +4. Tombstone metadata without releasing the job identifier. +5. Record `METADATA_TOMBSTONED` and `ARTIFACT_CLEANUP_PENDING`. +6. Re-read under the same lifecycle fence. A non-empty digest must match. +7. Delete exact bytes or record a controlled read/delete/mismatch failure. +8. Startup and fixed-delay recovery replay a bounded deterministic batch. +9. A later same-tenant DELETE resumes or observes the receipt; another tenant + receives the same concealed not-found result. + +## Write fence + +All configured artifact operations pass through `LifecycleFencedArtifactStore`. +If an artifact publication holds the job lock first, deletion waits, snapshots +that generation, and removes it. If deletion creates a receipt first, every +later `putPdf` fails closed. This prevents a conversion that began before +metadata tombstoning from recreating bytes after cleanup completion. + +The lock is process-local and fixed-memory. Multi-instance adapters must use an +object generation precondition, durable lease, distributed lock, or +transactional outbox consumer fence. + +## Backpressure and recovery + +`clearfolio.artifact-deletion-cleanup.max-receipts-per-run` defaults to `100`. +`clearfolio.artifact-deletion-cleanup.retry-delay-ms` defaults to `30000`. +One receipt failure is isolated from later receipts in the selected batch. +Cleanup has no terminal retry count because silently retaining confidential +bytes is not an acceptable terminal state. + +## Aggregate evidence + +`ArtifactDeletionMetrics` uses JDK `LongAdder` counters and the receipt store's +current pending count. It exposes only completed, failed, and pending aggregate +values. It stores no labels or identifiers and introduces no dependency. Host +applications may bridge these values to their existing observability plane. + +## Verification contract + +Tests must prove: + +- tenant concealment and missing-context failure; +- receipt before metadata mutation; +- exact digest and absence-sentinel behavior; +- read/delete/mismatch failure persistence and retry; +- restart and scheduled recovery; +- bounded batch isolation; +- repeated same-tenant DELETE idempotency; +- cross-tenant receipt concealment; +- write-after-receipt rejection; +- in-flight publication/deletion serialization; +- delegate-mode persistence for filesystem and volatility for in-memory storage; +- decorator delegation for every non-deletion method; +- aggregate evidence without high-cardinality input; +- 100% production line and branch coverage and warning-free public Javadocs. + +Exact-head CI, Security Scan, SAST, fuzzing, CodeRabbit, OpenCode/Noema, Strix, +zero unresolved threads, and independent protected-branch approval remain merge +gates. + +## References + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110; +STD 97). Internet Engineering Task Force. + +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*. diff --git a/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java b/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java index f29d3038c..119270c0a 100644 --- a/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java +++ b/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java @@ -3,12 +3,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; /** * Entry point for the Clearfolio Viewer Spring Boot application. */ @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class ClearfolioViewerApplication { /** diff --git a/src/main/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStore.java new file mode 100644 index 000000000..1ab0c024f --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStore.java @@ -0,0 +1,102 @@ +package com.clearfolio.viewer.artifact; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import com.clearfolio.viewer.lifecycle.ArtifactDeletionReceiptStore; +import com.clearfolio.viewer.lifecycle.ArtifactLifecycleLockRegistry; + +/** + * Artifact-store decorator that fences writes after deletion intent is durable. + * + *

Every artifact operation is serialized through the same per-job lifecycle + * lock used by the deletion coordinator. A write is rejected as soon as a + * durable deletion receipt exists, including while cleanup is pending or failed, + * so an already-running conversion cannot recreate bytes after deletion + * completion in the reference process.

+ * + *

Remote or multi-instance adapters must provide the equivalent object + * generation precondition or distributed lifecycle fence.

+ */ +public final class LifecycleFencedArtifactStore implements ArtifactStore { + + private final ArtifactStore delegate; + private final ArtifactDeletionReceiptStore receiptStore; + private final ArtifactLifecycleLockRegistry lifecycleLocks; + + /** + * Creates a standalone deletion-aware artifact boundary using the shared + * process-local lifecycle locks. + * + * @param delegate underlying artifact store + * @param receiptStore durable deletion receipt store + * @throws NullPointerException when a required collaborator is absent + */ + public LifecycleFencedArtifactStore( + ArtifactStore delegate, + ArtifactDeletionReceiptStore receiptStore + ) { + this(delegate, receiptStore, ArtifactLifecycleLockRegistry.shared()); + } + + /** + * Creates a deletion-aware artifact-store boundary. + * + * @param delegate underlying artifact store + * @param receiptStore durable deletion receipt store + * @param lifecycleLocks per-job lifecycle serialization boundary + * @throws NullPointerException when a required collaborator is absent + */ + public LifecycleFencedArtifactStore( + ArtifactStore delegate, + ArtifactDeletionReceiptStore receiptStore, + ArtifactLifecycleLockRegistry lifecycleLocks + ) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.receiptStore = Objects.requireNonNull(receiptStore, "receiptStore"); + this.lifecycleLocks = Objects.requireNonNull(lifecycleLocks, "lifecycleLocks"); + } + + /** + * Stores bytes only while no durable deletion receipt exists. + * + * @param docId document identifier + * @param pdfBytes complete PDF bytes + * @throws IllegalStateException when deletion intent already exists + */ + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + lifecycleLocks.withJobLock(docId, () -> { + if (receiptStore.findByJobId(docId).isPresent()) { + throw new IllegalStateException("artifact write rejected for deleted lifecycle"); + } + delegate.putPdf(docId, pdfBytes); + return null; + }); + } + + /** + * Reads bytes under the per-job lifecycle lock. + * + * @param docId document identifier + * @return stored bytes when present + */ + @Override + public Optional getPdf(UUID docId) { + return lifecycleLocks.withJobLock(docId, () -> delegate.getPdf(docId)); + } + + /** + * Deletes bytes under the per-job lifecycle lock. + * + * @param docId document identifier + */ + @Override + public void deletePdf(UUID docId) { + lifecycleLocks.withJobLock(docId, () -> { + delegate.deletePdf(docId); + return null; + }); + } +} diff --git a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java index 2b9d1f4c6..d23341410 100644 --- a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java +++ b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java @@ -8,6 +8,9 @@ import com.clearfolio.viewer.artifact.ArtifactStore; import com.clearfolio.viewer.artifact.FileSystemArtifactStore; import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.artifact.LifecycleFencedArtifactStore; +import com.clearfolio.viewer.lifecycle.ArtifactDeletionReceiptStore; +import com.clearfolio.viewer.lifecycle.ArtifactLifecycleLockRegistry; /** * Configures the artifact store implementation used by conversion and serving. @@ -23,14 +26,47 @@ public ArtifactStoreConfig() { } /** - * Creates the artifact store selected by configuration; the disk-backed - * store is the default so artifacts survive application restarts. + * Creates the selected artifact store behind a lifecycle fence. + * + *

The disk-backed delegate is the default so artifacts survive restart. + * The wrapper serializes artifact operations with deletion and rejects writes + * after durable deletion intent exists.

* * @param artifactStoreProperties artifact store configuration values - * @return configured artifact store + * @param receiptStore durable artifact deletion receipt store + * @param lifecycleLocks per-job conversion/deletion serialization boundary + * @return configured deletion-aware artifact store */ @Bean + public ArtifactStore artifactStore( + ArtifactStoreProperties artifactStoreProperties, + ArtifactDeletionReceiptStore receiptStore, + ArtifactLifecycleLockRegistry lifecycleLocks + ) { + return new LifecycleFencedArtifactStore( + createDelegate(artifactStoreProperties), + receiptStore, + lifecycleLocks + ); + } + + /** + * Creates the historical standalone artifact-store selection without + * deletion integration. + * + *

This overload preserves source compatibility for callers that construct + * the configuration directly. Standalone applications that enable durable + * deletion should call the three-argument factory so the artifact store and + * deletion coordinator share one receipt store and lifecycle-lock registry.

+ * + * @param artifactStoreProperties artifact store configuration values + * @return selected filesystem or in-memory artifact store + */ public ArtifactStore artifactStore(ArtifactStoreProperties artifactStoreProperties) { + return createDelegate(artifactStoreProperties); + } + + private ArtifactStore createDelegate(ArtifactStoreProperties artifactStoreProperties) { if (artifactStoreProperties.isInMemoryMode()) { return new InMemoryArtifactStore(); } diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 91872ceb1..b61532d06 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -27,6 +27,9 @@ import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + /** * Exposes tenant-scoped administrative conversion-job operations. * @@ -113,12 +116,16 @@ public AdminJobListResponse getAllJobs( /** * Deletes one conversion job owned by the authenticated tenant. * + *

The durable deletion boundary performs filesystem or object-store I/O. + * It is therefore subscribed on Reactor's bounded-elastic scheduler rather + * than executed on a WebFlux event-loop thread.

+ * * @param jobId conversion job identifier * @param headers signed gateway claim headers - * @return no content on success + * @return reactive no-content response on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob( + public Mono> deleteJob( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers ) { @@ -129,42 +136,43 @@ public ResponseEntity deleteJob( jobId ); - boolean deleted; - try { - deleted = conversionService.deleteJob(jobId, context); - } catch (RuntimeException ex) { - auditLogger.record( - context, - Action.DELETE_JOB, - Outcome.FAILED, - HttpStatus.INTERNAL_SERVER_ERROR, - jobId, - null - ); - throw ex; - } + return Mono.fromCallable(() -> { + try { + return conversionService.deleteJob(jobId, context); + } catch (RuntimeException ex) { + auditLogger.record( + context, + Action.DELETE_JOB, + Outcome.FAILED, + HttpStatus.INTERNAL_SERVER_ERROR, + jobId, + null + ); + throw ex; + } + }).subscribeOn(Schedulers.boundedElastic()).map(deleted -> { + if (!deleted) { + auditLogger.record( + context, + Action.DELETE_JOB, + Outcome.NOT_FOUND, + HttpStatus.NOT_FOUND, + jobId, + null + ); + throw notFound(); + } - if (!deleted) { auditLogger.record( context, Action.DELETE_JOB, - Outcome.NOT_FOUND, - HttpStatus.NOT_FOUND, + Outcome.ALLOWED, + HttpStatus.NO_CONTENT, jobId, null ); - throw notFound(); - } - - auditLogger.record( - context, - Action.DELETE_JOB, - Outcome.ALLOWED, - HttpStatus.NO_CONTENT, - jobId, - null - ); - return ResponseEntity.noContent().build(); + return ResponseEntity.noContent().build(); + }); } /** diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java new file mode 100644 index 000000000..a673fdd6a --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java @@ -0,0 +1,346 @@ +package com.clearfolio.viewer.lifecycle; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Coordinates receipt-first metadata tombstoning and restart-safe artifact cleanup. + * + *

Each document lifecycle is serialized by a fixed-memory per-job lock shared + * with conversion. Different document identifiers may progress concurrently, + * so a slow artifact store cannot serialize every deletion and recovery task in + * the process. Replaceable durable adapters may use transactions, + * object-generation preconditions, compare-and-set state, or a single-consumer + * outbox while preserving the same per-generation contract.

+ */ +@Component +public class ArtifactDeletionCoordinator { + + static final String ABSENT_ARTIFACT_CHECKSUM = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + private static final String FAILURE_CHECKSUM_MISMATCH = "artifact_checksum_mismatch"; + private static final String FAILURE_DELETE = "artifact_store_delete_failed"; + private static final String FAILURE_READ = "artifact_store_read_failed"; + private static final Logger log = LoggerFactory.getLogger(ArtifactDeletionCoordinator.class); + private static final HexFormat HEX_FORMAT = HexFormat.of(); + + private final ConversionJobRepository repository; + private final ArtifactStore artifactStore; + private final ArtifactDeletionReceiptStore receiptStore; + private final ArtifactDeletionMetrics metrics; + private final ArtifactLifecycleLockRegistry lifecycleLocks; + private final int maxReceiptsPerRun; + + /** + * Creates the Spring-managed coordinator and validates the recovery bound. + * + * @param repository conversion-job metadata repository + * @param artifactStore document artifact store + * @param receiptStore durable deletion receipt store + * @param metrics low-cardinality cleanup evidence + * @param lifecycleLocks per-job conversion/deletion serialization boundary + * @param maxReceiptsPerRun maximum incomplete receipts processed in one pass + * @throws NullPointerException when a required collaborator is absent + * @throws IllegalArgumentException when the recovery batch bound is not positive + */ + @Autowired + public ArtifactDeletionCoordinator( + ConversionJobRepository repository, + ArtifactStore artifactStore, + ArtifactDeletionReceiptStore receiptStore, + ArtifactDeletionMetrics metrics, + ArtifactLifecycleLockRegistry lifecycleLocks, + @Value("${clearfolio.artifact-deletion-cleanup.max-receipts-per-run:100}") + int maxReceiptsPerRun + ) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.artifactStore = Objects.requireNonNull(artifactStore, "artifactStore"); + this.receiptStore = Objects.requireNonNull(receiptStore, "receiptStore"); + this.metrics = Objects.requireNonNull(metrics, "metrics"); + this.lifecycleLocks = Objects.requireNonNull(lifecycleLocks, "lifecycleLocks"); + if (maxReceiptsPerRun <= 0) { + throw new IllegalArgumentException("maxReceiptsPerRun must be positive"); + } + this.maxReceiptsPerRun = maxReceiptsPerRun; + } + + /** + * Creates a standalone coordinator using the process-wide lifecycle locks. + * + * @param repository conversion-job metadata repository + * @param artifactStore document artifact store + * @param receiptStore durable deletion receipt store + * @param metrics low-cardinality cleanup evidence + * @param maxReceiptsPerRun maximum incomplete receipts processed in one pass + */ + public ArtifactDeletionCoordinator( + ConversionJobRepository repository, + ArtifactStore artifactStore, + ArtifactDeletionReceiptStore receiptStore, + ArtifactDeletionMetrics metrics, + int maxReceiptsPerRun + ) { + this( + repository, + artifactStore, + receiptStore, + metrics, + ArtifactLifecycleLockRegistry.shared(), + maxReceiptsPerRun + ); + } + + /** + * Deletes one tenant-owned job and starts durable artifact cleanup. + * + *

Artifact-store failure is retained as a retryable receipt. A repeated + * request by the same tenant resumes or observes the existing receipt and + * returns the same intended result without recreating work.

+ * + * @param jobId permanently reserved conversion-job identifier + * @param tenantId authenticated tenant identifier + * @return true when an owned job entered or already belongs to the durable + * deletion lifecycle; false for missing or cross-tenant identifiers + * @throws NullPointerException when either identifier is absent + */ + public boolean deleteForTenant(UUID jobId, String tenantId) { + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + String requiredTenantId = Objects.requireNonNull(tenantId, "tenantId").strip(); + return lifecycleLocks.withJobLock( + requiredJobId, + () -> deleteForTenantLocked(requiredJobId, requiredTenantId) + ); + } + + /** + * Preserves the legacy unscoped deletion entry point using durable receipts + * whenever metadata identifies the owning lifecycle. + * + * @param jobId permanently reserved conversion-job identifier + * @throws NullPointerException when the identifier is absent + */ + public void deleteGlobally(UUID jobId) { + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + lifecycleLocks.withJobLock(requiredJobId, () -> { + deleteGloballyLocked(requiredJobId); + return null; + }); + } + + /** + * Replays one bounded deterministic batch of incomplete deletion receipts. + * + *

A failure in one receipt is isolated so later receipts in the same + * batch remain eligible. The receipt remains authoritative recovery evidence.

+ * + * @return number of receipts selected for this recovery pass + */ + public int retryPendingWork() { + List pending = receiptStore.pendingReceipts(); + int selected = Math.min(maxReceiptsPerRun, pending.size()); + for (int index = 0; index < selected; index++) { + try { + resumeReceipt(pending.get(index)); + } catch (RuntimeException exception) { + metrics.recordFailed(); + log.warn( + "Artifact deletion recovery retained an incomplete receipt. cause={}", + exception.getClass().getName() + ); + } + } + return selected; + } + + /** Replays incomplete receipts when the application becomes ready. */ + @EventListener(ApplicationReadyEvent.class) + public void recoverPendingAfterStartup() { + retryPendingWork(); + } + + /** Replays a bounded receipt batch after the configured fixed delay. */ + @Scheduled(fixedDelayString = "${clearfolio.artifact-deletion-cleanup.retry-delay-ms:30000}") + public void retryPendingAfterDelay() { + retryPendingWork(); + } + + void resumeReceipt(ArtifactDeletionReceipt candidate) { + ArtifactDeletionReceipt requiredCandidate = Objects.requireNonNull(candidate, "candidate"); + lifecycleLocks.withJobLock(requiredCandidate.jobId(), () -> { + resumeReceiptLocked(requiredCandidate); + return null; + }); + } + + private boolean deleteForTenantLocked(UUID jobId, String tenantId) { + Optional existing = repository.findByTenantAndId(tenantId, jobId); + if (existing.isEmpty()) { + return resumeExistingReceiptForTenantLocked(jobId, tenantId); + } + ArtifactDeletionReceipt receipt = requestReceipt( + tenantId, + jobId, + snapshotChecksum(jobId) + ); + resumeReceiptLocked(receipt); + return true; + } + + private void deleteGloballyLocked(UUID jobId) { + Optional existingReceipt = receiptStore.findByJobId(jobId); + if (existingReceipt.isPresent()) { + resumeReceiptLocked(existingReceipt.get()); + return; + } + Optional existing = repository.findById(jobId); + if (existing.isEmpty()) { + repository.deleteById(jobId); + return; + } + ConversionJob job = existing.get(); + ArtifactDeletionReceipt receipt = requestReceipt( + job.getTenantId(), + jobId, + snapshotChecksum(jobId) + ); + resumeReceiptLocked(receipt); + } + + private boolean resumeExistingReceiptForTenantLocked(UUID jobId, String tenantId) { + Optional existingReceipt = receiptStore.findByJobId(jobId); + if (existingReceipt.isEmpty() || !existingReceipt.get().tenantId().equals(tenantId)) { + return false; + } + resumeReceiptLocked(existingReceipt.get()); + return true; + } + + private void resumeReceiptLocked(ArtifactDeletionReceipt candidate) { + ArtifactDeletionReceipt receipt = receiptStore.findByJobId(candidate.jobId()) + .orElseThrow(() -> new IllegalStateException("artifact deletion receipt not found")); + switch (receipt.state()) { + case DELETION_REQUESTED -> resumeRequested(receipt); + case METADATA_TOMBSTONED -> queueAndAttempt(receipt); + case ARTIFACT_CLEANUP_PENDING -> attemptCleanup(receipt); + case ARTIFACT_CLEANUP_FAILED -> queueAndAttempt(receipt); + case ARTIFACT_CLEANUP_COMPLETED -> { + // Completed receipts remain for idempotency and require no work. + } + } + } + + private void resumeRequested(ArtifactDeletionReceipt receipt) { + boolean deleted = repository.deleteByTenantAndId(receipt.tenantId(), receipt.jobId()); + if (!deleted && repository.findByTenantAndId(receipt.tenantId(), receipt.jobId()).isPresent()) { + throw new IllegalStateException("tenant-scoped metadata tombstone was not applied"); + } + ArtifactDeletionReceipt tombstoned = receiptStore.markMetadataTombstoned( + receipt.jobId(), + Instant.now() + ); + queueAndAttempt(tombstoned); + } + + private void queueAndAttempt(ArtifactDeletionReceipt receipt) { + ArtifactDeletionReceipt pending = receiptStore.markCleanupPending( + receipt.jobId(), + Instant.now() + ); + attemptCleanup(pending); + } + + private void attemptCleanup(ArtifactDeletionReceipt receipt) { + Optional currentArtifact; + try { + currentArtifact = Objects.requireNonNull( + artifactStore.getPdf(receipt.jobId()), + "artifactStore.getPdf" + ); + } catch (RuntimeException exception) { + recordFailure(receipt, FAILURE_READ); + return; + } + if (currentArtifact.isPresent() + && !ABSENT_ARTIFACT_CHECKSUM.equals(receipt.artifactChecksum()) + && !receipt.artifactChecksum().equals(sha256(currentArtifact.orElseThrow()))) { + recordFailure(receipt, FAILURE_CHECKSUM_MISMATCH); + return; + } + try { + artifactStore.deletePdf(receipt.jobId()); + } catch (RuntimeException exception) { + recordFailure(receipt, FAILURE_DELETE); + return; + } + receiptStore.markCleanupCompleted(receipt.jobId(), Instant.now()); + metrics.recordCompleted(); + } + + private void recordFailure(ArtifactDeletionReceipt receipt, String failureCode) { + receiptStore.recordCleanupFailure(receipt.jobId(), failureCode, Instant.now()); + metrics.recordFailed(); + } + + private ArtifactDeletionReceipt requestReceipt( + String tenantId, + UUID jobId, + String artifactChecksum + ) { + Optional existing = receiptStore.findByJobId(jobId); + if (existing.isPresent()) { + ArtifactDeletionReceipt receipt = existing.get(); + if (!receipt.tenantId().equals(tenantId) + || !receipt.artifactChecksum().equals(artifactChecksum)) { + throw new IllegalStateException("artifact deletion receipt conflicts with the active lifecycle"); + } + return receipt; + } + UUID requestId = UUID.randomUUID(); + return receiptStore.request( + requestId, + tenantId, + jobId, + artifactChecksum, + "cleanup-v1:" + requestId.toString().replace("-", ""), + Instant.now() + ); + } + + private String snapshotChecksum(UUID jobId) { + Optional artifact = Objects.requireNonNull( + artifactStore.getPdf(jobId), + "artifactStore.getPdf" + ); + return artifact.map(ArtifactDeletionCoordinator::sha256) + .orElse(ABSENT_ARTIFACT_CHECKSUM); + } + + private static String sha256(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HEX_FORMAT.formatHex(digest.digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 digest unavailable", exception); + } + } +} diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionMetrics.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionMetrics.java new file mode 100644 index 000000000..36c1ed96a --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionMetrics.java @@ -0,0 +1,72 @@ +package com.clearfolio.viewer.lifecycle; + +import java.util.Objects; +import java.util.concurrent.atomic.LongAdder; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Dependency-free low-cardinality evidence for durable artifact deletion. + * + *

The component retains aggregate outcomes only. Tenant identifiers, job + * identifiers, checksums, exception messages, filenames, tokens, and storage + * paths are never accepted as dimensions. A host may bridge these values into + * its existing observability plane without coupling this module to a metrics + * framework or management HTTP endpoint.

+ */ +@Component +public final class ArtifactDeletionMetrics { + + private final ArtifactDeletionReceiptStore receiptStore; + private final LongAdder completedAttempts = new LongAdder(); + private final LongAdder failedAttempts = new LongAdder(); + + /** + * Creates aggregate cleanup evidence backed by the durable receipt store. + * + * @param receiptStore durable receipt store that supplies the pending count + * @throws NullPointerException when the receipt store is absent + */ + @Autowired + public ArtifactDeletionMetrics(ArtifactDeletionReceiptStore receiptStore) { + this.receiptStore = Objects.requireNonNull(receiptStore, "receiptStore"); + } + + /** Records one cleanup attempt that reached durable completion. */ + public void recordCompleted() { + completedAttempts.increment(); + } + + /** Records one cleanup attempt retained for controlled retry. */ + public void recordFailed() { + failedAttempts.increment(); + } + + /** + * Returns the cumulative completed-attempt count. + * + * @return completed cleanup attempt count + */ + public long completedAttempts() { + return completedAttempts.sum(); + } + + /** + * Returns the cumulative failed-attempt count. + * + * @return failed cleanup attempt count + */ + public long failedAttempts() { + return failedAttempts.sum(); + } + + /** + * Returns the current number of incomplete durable receipts. + * + * @return pending receipt count + */ + public int pendingReceipts() { + return receiptStore.pendingCount(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java new file mode 100644 index 000000000..8bb61c263 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java @@ -0,0 +1,81 @@ +package com.clearfolio.viewer.lifecycle; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import org.springframework.stereotype.Component; + +/** + * Provides fixed-memory, process-local serialization for one document lifecycle. + * + *

Conversion and deletion must not write or remove bytes for the same + * permanently reserved job identifier at the same time. The registry hashes + * identifiers onto a fixed number of lock stripes, so memory use remains + * bounded while unrelated jobs may proceed concurrently unless they share a + * stripe.

+ * + *

This reference registry protects one JVM. Multi-instance deployments must + * provide an equivalent distributed generation fence, object-version + * precondition, or transactional outbox consumer guarantee.

+ */ +@Component +public final class ArtifactLifecycleLockRegistry { + + private static final int DEFAULT_STRIPE_COUNT = 256; + private static final ArtifactLifecycleLockRegistry SHARED = + new ArtifactLifecycleLockRegistry(DEFAULT_STRIPE_COUNT); + + private final ReentrantLock[] locks; + + /** + * Creates a registry with the default fixed number of lock stripes. + */ + public ArtifactLifecycleLockRegistry() { + this(DEFAULT_STRIPE_COUNT); + } + + ArtifactLifecycleLockRegistry(int stripeCount) { + if (stripeCount <= 0) { + throw new IllegalArgumentException("stripeCount must be positive"); + } + this.locks = new ReentrantLock[stripeCount]; + for (int index = 0; index < stripeCount; index++) { + locks[index] = new ReentrantLock(); + } + } + + /** + * Returns the process-wide registry used by standalone constructors. + * + * @return shared fixed-memory lifecycle lock registry + */ + public static ArtifactLifecycleLockRegistry shared() { + return SHARED; + } + + /** + * Executes one action while holding the stripe for a conversion job. + * + *

The lock is always released, including when the supplied action throws. + * The action's return value or runtime exception is passed through unchanged.

+ * + * @param jobId permanently reserved conversion-job identifier + * @param action work that reads or mutates metadata and artifact bytes + * @param action return type + * @return value returned by the action + * @throws NullPointerException when the identifier or action is absent + */ + public T withJobLock(UUID jobId, Supplier action) { + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + Supplier requiredAction = Objects.requireNonNull(action, "action"); + ReentrantLock lock = locks[Math.floorMod(requiredJobId.hashCode(), locks.length)]; + lock.lock(); + try { + return requiredAction.get(); + } finally { + lock.unlock(); + } + } +} diff --git a/src/main/java/com/clearfolio/viewer/service/DurableDocumentDeletionService.java b/src/main/java/com/clearfolio/viewer/service/DurableDocumentDeletionService.java new file mode 100644 index 000000000..89c76c8ac --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/service/DurableDocumentDeletionService.java @@ -0,0 +1,143 @@ +package com.clearfolio.viewer.service; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.lifecycle.ArtifactDeletionCoordinator; +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Primary document-service decorator that routes deletion through the durable + * artifact-cleanup lifecycle while preserving the existing conversion service. + * + *

Submission, lookup, retry, and listing remain owned by + * {@link DefaultDocumentConversionService}. Only deletion is intercepted, which + * keeps the cleanup worker independently replaceable for standalone and MSA + * deployments without duplicating conversion logic.

+ */ +@Service +@Primary +public final class DurableDocumentDeletionService implements DocumentConversionService { + + private final DefaultDocumentConversionService delegate; + private final ArtifactDeletionCoordinator deletionCoordinator; + + /** + * Creates the durable deletion decorator. + * + * @param delegate existing conversion service used for non-deletion behavior + * @param deletionCoordinator receipt-first artifact deletion coordinator + * @throws NullPointerException when a required collaborator is absent + */ + public DurableDocumentDeletionService( + DefaultDocumentConversionService delegate, + ArtifactDeletionCoordinator deletionCoordinator + ) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.deletionCoordinator = Objects.requireNonNull(deletionCoordinator, "deletionCoordinator"); + } + + /** + * {@inheritDoc} + */ + @Override + public UUID submit(MultipartFile file) { + return delegate.submit(file); + } + + /** + * {@inheritDoc} + */ + @Override + public UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest) { + return delegate.submit(file, overrideRequest); + } + + /** + * {@inheritDoc} + */ + @Override + public UUID submit( + MultipartFile file, + PolicyOverrideRequest overrideRequest, + TenantContext tenantContext + ) { + return delegate.submit(file, overrideRequest, tenantContext); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional getJob(UUID jobId) { + return delegate.getJob(jobId); + } + + /** + * {@inheritDoc} + */ + @Override + public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { + return delegate.retryDeadLettered(jobId, operatorId); + } + + /** + * {@inheritDoc} + */ + @Override + public RetryDeadLetterResult retryDeadLettered( + UUID jobId, + TenantContext tenantContext, + String operatorId + ) { + return delegate.retryDeadLettered(jobId, tenantContext, operatorId); + } + + /** + * Routes tenant-owned deletion through the durable receipt lifecycle. + * + * @param jobId conversion job identifier + * @param tenantContext authenticated tenant context + * @return false when the context is absent or the job is missing/cross-tenant; + * true when the deletion lifecycle is accepted or already exists + */ + @Override + public boolean deleteJob(UUID jobId, TenantContext tenantContext) { + if (tenantContext == null) { + return false; + } + return deletionCoordinator.deleteForTenant(jobId, tenantContext.tenantId()); + } + + /** + * Routes the legacy compatibility deletion through the durable coordinator. + * + * @param jobId conversion job identifier + */ + @Override + public void deleteJob(UUID jobId) { + deletionCoordinator.deleteGlobally(jobId); + } + + /** + * {@inheritDoc} + */ + @Override + public Iterable getJobsForTenant(TenantContext tenantContext) { + return delegate.getJobsForTenant(tenantContext); + } + + /** + * {@inheritDoc} + */ + @Override + public Iterable getAllJobs() { + return delegate.getAllJobs(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9826263b5..e2ee78c15 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,6 +13,13 @@ spring: import: "optional:configtree:${CLEARFOLIO_SECRET_CONFIG_DIR:/run/secrets/clearfolio/}" codec: max-in-memory-size: ${conversion.max-upload-size-bytes} + task: + scheduling: + # Keep bounded artifact cleanup from monopolizing Spring's default + # single scheduler thread. Per-job lifecycle locks still serialize the + # same document generation while unrelated scheduled work can progress. + pool: + size: 2 conversion: blocked-extensions: @@ -38,6 +45,11 @@ clearfolio: # 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 + artifact-deletion-cleanup: + # Recovery processes a bounded deterministic batch and retries without + # dropping confidential-byte cleanup evidence after a storage failure. + retry-delay-ms: 30000 + max-receipts-per-run: 100 viewer: security: diff --git a/src/test/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStoreTest.java b/src/test/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStoreTest.java new file mode 100644 index 000000000..8fd8bcaa9 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/LifecycleFencedArtifactStoreTest.java @@ -0,0 +1,100 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.lifecycle.ArtifactDeletionLedger; +import com.clearfolio.viewer.lifecycle.ArtifactLifecycleLockRegistry; + +/** + * Verifies deletion fencing at the artifact storage boundary. + */ +class LifecycleFencedArtifactStoreTest { + + @Test + void constructorRejectsMissingCollaborators() { + ArtifactStore delegate = new InMemoryArtifactStore(); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactLifecycleLockRegistry locks = new ArtifactLifecycleLockRegistry(); + + assertThrows( + NullPointerException.class, + () -> new LifecycleFencedArtifactStore(null, ledger, locks) + ); + assertThrows( + NullPointerException.class, + () -> new LifecycleFencedArtifactStore(delegate, null, locks) + ); + assertThrows( + NullPointerException.class, + () -> new LifecycleFencedArtifactStore(delegate, ledger, null) + ); + assertThrows( + NullPointerException.class, + () -> new LifecycleFencedArtifactStore(null, ledger) + ); + } + + @Test + void sharedLockConstructorDelegatesNormalPutReadAndDelete() { + LifecycleFencedArtifactStore store = new LifecycleFencedArtifactStore( + new InMemoryArtifactStore(), + new ArtifactDeletionLedger() + ); + UUID jobId = UUID.randomUUID(); + byte[] bytes = new byte[] {1, 2, 3}; + + store.putPdf(jobId, bytes); + assertArrayEquals(bytes, store.getPdf(jobId).orElseThrow()); + store.deletePdf(jobId); + + assertTrue(store.getPdf(jobId).isEmpty()); + } + + @Test + void durableDeletionReceiptRejectsEveryLaterArtifactWrite() { + InMemoryArtifactStore delegate = new InMemoryArtifactStore(); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + LifecycleFencedArtifactStore store = new LifecycleFencedArtifactStore( + delegate, + ledger, + new ArtifactLifecycleLockRegistry() + ); + UUID jobId = UUID.randomUUID(); + ledger.request( + UUID.randomUUID(), + "tenant-a", + jobId, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "cleanup-v1:fenced-write", + Instant.now() + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> store.putPdf(jobId, new byte[] {9}) + ); + + assertTrue(exception.getMessage().contains("deleted lifecycle")); + assertTrue(delegate.getPdf(jobId).isEmpty()); + } + + @Test + void missingIdentifiersFailClosedAtTheSharedLockBoundary() { + LifecycleFencedArtifactStore store = new LifecycleFencedArtifactStore( + new InMemoryArtifactStore(), + new ArtifactDeletionLedger(), + new ArtifactLifecycleLockRegistry() + ); + + assertThrows(NullPointerException.class, () -> store.putPdf(null, new byte[0])); + assertThrows(NullPointerException.class, () -> store.getPdf(null)); + assertThrows(NullPointerException.class, () -> store.deletePdf(null)); + } +} diff --git a/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java b/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java index 87da07f94..b4bdf3d29 100644 --- a/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ArtifactStoreConfigTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Path; @@ -13,33 +14,64 @@ import com.clearfolio.viewer.artifact.ArtifactStore; import com.clearfolio.viewer.artifact.FileSystemArtifactStore; import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.artifact.LifecycleFencedArtifactStore; +import com.clearfolio.viewer.lifecycle.ArtifactDeletionLedger; +import com.clearfolio.viewer.lifecycle.ArtifactLifecycleLockRegistry; +/** + * Verifies artifact-store mode selection and standalone factory compatibility. + */ class ArtifactStoreConfigTest { @TempDir Path tempDir; @Test - void filesystemModeCreatesDiskBackedStore() { + void filesystemModeCreatesRestartDurableFencedStore() { ArtifactStoreProperties properties = new ArtifactStoreProperties(); properties.setRootDir(tempDir.resolve("artifacts").toString()); - - ArtifactStore store = new ArtifactStoreConfig().artifactStore(properties); - - assertInstanceOf(FileSystemArtifactStore.class, store); UUID docId = UUID.randomUUID(); byte[] bytes = "%PDF-1.7".getBytes(StandardCharsets.UTF_8); - store.putPdf(docId, bytes); - assertArrayEquals(bytes, store.getPdf(docId).orElseThrow()); + ArtifactStore firstStore = configuredStore(properties); + + assertInstanceOf(LifecycleFencedArtifactStore.class, firstStore); + firstStore.putPdf(docId, bytes); + + ArtifactStore restartedStore = configuredStore(properties); + assertArrayEquals(bytes, restartedStore.getPdf(docId).orElseThrow()); } @Test - void inMemoryModeCreatesVolatileStore() { + void inMemoryModeCreatesVolatileFencedStore() { ArtifactStoreProperties properties = new ArtifactStoreProperties(); properties.setMode(ArtifactStoreProperties.MODE_IN_MEMORY); + UUID docId = UUID.randomUUID(); + ArtifactStore firstStore = configuredStore(properties); + firstStore.putPdf(docId, new byte[] {1}); + + ArtifactStore restartedStore = configuredStore(properties); - ArtifactStore store = new ArtifactStoreConfig().artifactStore(properties); + assertInstanceOf(LifecycleFencedArtifactStore.class, restartedStore); + assertTrue(restartedStore.getPdf(docId).isEmpty()); + } + + @Test + void historicalStandaloneFactoryPreservesBothDelegateModes() { + ArtifactStoreConfig config = new ArtifactStoreConfig(); + ArtifactStoreProperties filesystem = new ArtifactStoreProperties(); + filesystem.setRootDir(tempDir.resolve("legacy-artifacts").toString()); + ArtifactStoreProperties inMemory = new ArtifactStoreProperties(); + inMemory.setMode(ArtifactStoreProperties.MODE_IN_MEMORY); + + assertInstanceOf(FileSystemArtifactStore.class, config.artifactStore(filesystem)); + assertInstanceOf(InMemoryArtifactStore.class, config.artifactStore(inMemory)); + } - assertInstanceOf(InMemoryArtifactStore.class, store); + private static ArtifactStore configuredStore(ArtifactStoreProperties properties) { + return new ArtifactStoreConfig().artifactStore( + properties, + new ArtifactDeletionLedger(), + new ArtifactLifecycleLockRegistry() + ); } } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index a7fb8313e..ca3bad4bd 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,5 +1,6 @@ package com.clearfolio.viewer.controller; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -14,6 +15,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -217,6 +219,22 @@ void deleteUsesTenantScopedServiceAndReportsFailuresGenerically() { verify(conversionService, never()).deleteJob(any(UUID.class)); } + @Test + void deleteRunsBlockingLifecycleOnBoundedElasticWorker() { + UUID jobId = UUID.randomUUID(); + AtomicReference workerThreadName = new AtomicReference<>(); + when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))) + .thenAnswer(invocation -> { + workerThreadName.set(Thread.currentThread().getName()); + return true; + }); + + deleteJob(jobId, signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE)) + .expectStatus().isNoContent(); + + assertTrue(workerThreadName.get().startsWith("boundedElastic-")); + } + @Test void retryAcceptedUsesDomainSeparatedActorFingerprint() { UUID jobId = UUID.randomUUID(); diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorCoverageTest.java new file mode 100644 index 000000000..c4ee3c497 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorCoverageTest.java @@ -0,0 +1,226 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Covers idempotency and defensive branches that require narrowly controlled + * repository and receipt-store state. + */ +class ArtifactDeletionCoordinatorCoverageTest { + + private static final String TENANT_ID = "tenant-north"; + private static final String OTHER_TENANT_ID = "tenant-south"; + private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + private static final byte[] ARTIFACT = "%PDF-1.7\nprivate".getBytes(StandardCharsets.UTF_8); + + @Test + void repeatedTenantDeletionUsesCompletedReceiptAfterMetadataIsGone() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + CountingArtifactStore artifactStore = new CountingArtifactStore(); + artifactStore.putPdf(JOB_ID, ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(ledger); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + metrics + ); + + assertTrue(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + ArtifactDeletionReceipt firstReceipt = ledger.findByJobId(JOB_ID).orElseThrow(); + assertTrue(firstReceipt.isCompleted()); + assertTrue(repository.findById(JOB_ID).isEmpty()); + + assertTrue(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + + assertSame(firstReceipt, ledger.findByJobId(JOB_ID).orElseThrow()); + assertEquals(1, artifactStore.deleteCalls()); + assertEquals(1L, metrics.completedAttempts()); + assertEquals(0, metrics.pendingReceipts()); + } + + @Test + void existingMatchingRequestedReceiptResumesWithoutCreatingAnotherIdentity() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt requested = ledger.request( + UUID.randomUUID(), + TENANT_ID, + JOB_ID, + sha256(ARTIFACT), + "cleanup-v1:existing", + Instant.now() + ); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger) + ); + + assertTrue(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + + ArtifactDeletionReceipt completed = ledger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(requested.requestId(), completed.requestId()); + assertTrue(completed.isCompleted()); + } + + @Test + void receiptForAnotherTenantRemainsConcealedWhenMetadataIsGone() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt receipt = ledger.request( + UUID.randomUUID(), + OTHER_TENANT_ID, + JOB_ID, + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + "cleanup-v1:other-tenant", + Instant.now() + ); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + new InMemoryArtifactStore(), + ledger, + new ArtifactDeletionMetrics(ledger) + ); + + assertFalse(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + assertSame(receipt, ledger.findByJobId(JOB_ID).orElseThrow()); + } + + @Test + void metadataTombstoneFailureLeavesRequestedReceiptForRecovery() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + when(repository.findByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(Optional.of(job())); + when(repository.deleteByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(false); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> coordinator.deleteForTenant(JOB_ID, TENANT_ID) + ); + + assertEquals("tenant-scoped metadata tombstone was not applied", exception.getMessage()); + assertEquals( + ArtifactDeletionState.DELETION_REQUESTED, + ledger.findByJobId(JOB_ID).orElseThrow().state() + ); + assertTrue(artifactStore.getPdf(JOB_ID).isPresent()); + } + + @Test + void aggregateMetricsExposeOnlyCountsAndCurrentPendingWork() { + ArtifactDeletionReceiptStore receiptStore = mock(ArtifactDeletionReceiptStore.class); + when(receiptStore.pendingCount()).thenReturn(3); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(receiptStore); + + metrics.recordCompleted(); + metrics.recordFailed(); + metrics.recordFailed(); + + assertEquals(1L, metrics.completedAttempts()); + assertEquals(2L, metrics.failedAttempts()); + assertEquals(3, metrics.pendingReceipts()); + assertThrows(NullPointerException.class, () -> new ArtifactDeletionMetrics(null)); + } + + private static ArtifactDeletionCoordinator coordinator( + ConversionJobRepository repository, + ArtifactStore artifactStore, + ArtifactDeletionReceiptStore receiptStore, + ArtifactDeletionMetrics metrics + ) { + return new ArtifactDeletionCoordinator( + repository, + artifactStore, + receiptStore, + metrics, + 100 + ); + } + + private static InMemoryConversionJobRepository repositoryWithJob() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + repository.save(job()); + return repository; + } + + private static ConversionJob job() { + return new ConversionJob( + JOB_ID, + TENANT_ID, + "subject-north", + "report.pdf", + "application/pdf", + "artifact-cleanup-hash", + ARTIFACT.length, + 3 + ); + } + + private static String sha256(byte[] bytes) { + try { + return java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(bytes) + ); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + } + + private static final class CountingArtifactStore implements ArtifactStore { + private final InMemoryArtifactStore delegate = new InMemoryArtifactStore(); + private final AtomicInteger deleteCalls = new AtomicInteger(); + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + delegate.putPdf(docId, pdfBytes); + } + + @Override + public Optional getPdf(UUID docId) { + return delegate.getPdf(docId); + } + + @Override + public void deletePdf(UUID docId) { + deleteCalls.incrementAndGet(); + delegate.deletePdf(docId); + } + + private int deleteCalls() { + return deleteCalls.get(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java new file mode 100644 index 000000000..c7d55c4b2 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java @@ -0,0 +1,612 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.Security; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport; +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.ProviderPosition; + +/** + * Verifies receipt-first metadata tombstoning, exact-lifecycle cleanup, bounded + * recovery, aggregate evidence, and restart behavior. + */ +@ResourceLock("java.security.Security.providers") +class ArtifactDeletionCoordinatorTest { + + private static final String TENANT_ID = "tenant-north"; + private static final String OTHER_TENANT_ID = "tenant-south"; + private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + private static final byte[] ORIGINAL_ARTIFACT = "%PDF-1.7\noriginal".getBytes(StandardCharsets.UTF_8); + private static final byte[] REPLACEMENT_ARTIFACT = "%PDF-1.7\nreplacement".getBytes(StandardCharsets.UTF_8); + + @TempDir + Path tempDirectory; + + @Test + void authorizedDeletionCompletesReceiptAndAggregateEvidence() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(ledger); + ArtifactDeletionCoordinator coordinator = coordinator(repository, artifactStore, ledger, metrics, 100); + + assertTrue(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + + assertTrue(repository.findById(JOB_ID).isEmpty()); + assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); + ArtifactDeletionReceipt receipt = ledger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, receipt.state()); + assertEquals(1L, metrics.completedAttempts()); + assertEquals(0L, metrics.failedAttempts()); + assertEquals(0, metrics.pendingReceipts()); + } + + @Test + void crossTenantAndMissingContextBoundariesDoNotTouchArtifactOrReceiptStores() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ArtifactStore artifactStore = mock(ArtifactStore.class); + ArtifactDeletionReceiptStore receiptStore = mock(ArtifactDeletionReceiptStore.class); + when(repository.findByTenantAndId(OTHER_TENANT_ID, JOB_ID)).thenReturn(Optional.empty()); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + receiptStore, + new ArtifactDeletionMetrics(receiptStore), + 100 + ); + + assertFalse(coordinator.deleteForTenant(JOB_ID, OTHER_TENANT_ID)); + assertThrows(NullPointerException.class, () -> coordinator.deleteForTenant(null, TENANT_ID)); + assertThrows(NullPointerException.class, () -> coordinator.deleteForTenant(JOB_ID, null)); + + verify(repository, never()).deleteByTenantAndId(OTHER_TENANT_ID, JOB_ID); + verifyNoInteractions(artifactStore); + verify(receiptStore, never()).request(any(), any(), any(), any(), any(), any()); + } + + @Test + void artifactSnapshotFailurePrecedesReceiptAndMetadataMutation() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + ArtifactStore artifactStore = throwingStore(true, false); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + 100 + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> coordinator.deleteForTenant(JOB_ID, TENANT_ID) + ); + + assertEquals("artifact read failed", exception.getMessage()); + assertTrue(repository.findById(JOB_ID).isPresent()); + assertTrue(ledger.findByJobId(JOB_ID).isEmpty()); + } + + @Test + void deleteFailureRemainsDurableAndCompletesOnRetry() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + FailingOnceArtifactStore artifactStore = new FailingOnceArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(ledger); + ArtifactDeletionCoordinator coordinator = coordinator(repository, artifactStore, ledger, metrics, 100); + + assertTrue(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + + ArtifactDeletionReceipt failed = ledger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, failed.state()); + assertEquals("artifact_store_delete_failed", failed.failureCode()); + assertEquals(1L, metrics.failedAttempts()); + assertEquals(1, metrics.pendingReceipts()); + + assertEquals(1, coordinator.retryPendingWork()); + + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + ledger.findByJobId(JOB_ID).orElseThrow().state() + ); + assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); + assertEquals(1L, metrics.completedAttempts()); + assertEquals(0, metrics.pendingReceipts()); + } + + @Test + void fileBackedFailedReceiptCompletesAfterRestart() { + Path ledgerPath = tempDirectory.resolve("artifact-deletion-receipts.log"); + InMemoryConversionJobRepository repository = repositoryWithJob(); + FailingOnceArtifactStore artifactStore = new FailingOnceArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger firstLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionCoordinator firstProcess = coordinator( + repository, + artifactStore, + firstLedger, + new ArtifactDeletionMetrics(firstLedger), + 100 + ); + assertTrue(firstProcess.deleteForTenant(JOB_ID, TENANT_ID)); + + ArtifactDeletionLedger restartedLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionCoordinator restartedProcess = coordinator( + repository, + artifactStore, + restartedLedger, + new ArtifactDeletionMetrics(restartedLedger), + 100 + ); + restartedProcess.recoverPendingAfterStartup(); + + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + restartedLedger.findByJobId(JOB_ID).orElseThrow().state() + ); + assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); + } + + @Test + void requestedAndTombstonedReceiptsResumeWithoutRestoringMetadata() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger requestedLedger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt requested = request( + requestedLedger, + JOB_ID, + sha256(ORIGINAL_ARTIFACT), + Instant.parse("2026-08-06T00:00:00Z") + ); + ArtifactDeletionCoordinator requestedCoordinator = coordinator( + repository, + artifactStore, + requestedLedger, + new ArtifactDeletionMetrics(requestedLedger), + 100 + ); + requestedCoordinator.resumeReceipt(requested); + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + requestedLedger.findByJobId(JOB_ID).orElseThrow().state() + ); + + UUID secondJobId = UUID.fromString("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"); + ArtifactDeletionLedger tombstonedLedger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt tombstoned = request( + tombstonedLedger, + secondJobId, + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + Instant.parse("2026-08-06T00:01:00Z") + ); + tombstoned = tombstonedLedger.markMetadataTombstoned(secondJobId, tombstoned.requestedAt()); + ArtifactDeletionCoordinator tombstonedCoordinator = coordinator( + repository, + artifactStore, + tombstonedLedger, + new ArtifactDeletionMetrics(tombstonedLedger), + 100 + ); + tombstonedCoordinator.resumeReceipt(tombstoned); + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + tombstonedLedger.findByJobId(secondJobId).orElseThrow().state() + ); + } + + @Test + void digestMismatchRetainsReplacementWhileAbsenceSentinelDeletesLateBytes() { + InMemoryArtifactStore mismatchStore = new InMemoryArtifactStore(); + mismatchStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ConversionJobRepository mismatchRepository = repositoryThatWritesOnDelete( + mismatchStore, + REPLACEMENT_ARTIFACT + ); + ArtifactDeletionLedger mismatchLedger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator mismatchCoordinator = coordinator( + mismatchRepository, + mismatchStore, + mismatchLedger, + new ArtifactDeletionMetrics(mismatchLedger), + 100 + ); + + assertTrue(mismatchCoordinator.deleteForTenant(JOB_ID, TENANT_ID)); + assertEquals( + "artifact_checksum_mismatch", + mismatchLedger.findByJobId(JOB_ID).orElseThrow().failureCode() + ); + assertTrue(Arrays.equals(REPLACEMENT_ARTIFACT, mismatchStore.getPdf(JOB_ID).orElseThrow())); + + InMemoryArtifactStore lateStore = new InMemoryArtifactStore(); + ConversionJobRepository lateRepository = repositoryThatWritesOnDelete(lateStore, REPLACEMENT_ARTIFACT); + ArtifactDeletionLedger lateLedger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator lateCoordinator = coordinator( + lateRepository, + lateStore, + lateLedger, + new ArtifactDeletionMetrics(lateLedger), + 100 + ); + assertTrue(lateCoordinator.deleteForTenant(JOB_ID, TENANT_ID)); + assertEquals( + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + lateLedger.findByJobId(JOB_ID).orElseThrow().artifactChecksum() + ); + assertTrue(lateStore.getPdf(JOB_ID).isEmpty()); + } + + @Test + void cleanupReadFailureAndNullStoreResultBecomeControlledRetryEvidence() { + for (ArtifactStore store : List.of(throwingStore(true, false), nullReturningStore())) { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt receipt = request( + ledger, + UUID.randomUUID(), + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + Instant.now() + ); + receipt = ledger.markMetadataTombstoned(receipt.jobId(), receipt.requestedAt()); + ArtifactDeletionCoordinator coordinator = coordinator( + new InMemoryConversionJobRepository(), + store, + ledger, + new ArtifactDeletionMetrics(ledger), + 100 + ); + + coordinator.resumeReceipt(receipt); + + assertEquals( + "artifact_store_read_failed", + ledger.findByJobId(receipt.jobId()).orElseThrow().failureCode() + ); + } + } + + @Test + void recoveryBatchIsBoundedIsolatesFailuresAndUsesScheduledEntryPoint() { + ArtifactDeletionReceipt failedLookup = receiptSnapshot( + UUID.fromString("00000000-0000-0000-0000-000000000001") + ); + ArtifactDeletionReceipt successful = receiptSnapshot( + UUID.fromString("00000000-0000-0000-0000-000000000002") + ); + ArtifactDeletionReceiptStore store = mock(ArtifactDeletionReceiptStore.class); + when(store.pendingReceipts()).thenReturn(List.of(failedLookup, successful)); + when(store.findByJobId(failedLookup.jobId())).thenReturn(Optional.empty()); + when(store.findByJobId(successful.jobId())).thenReturn(Optional.of(successful)); + when(store.markCleanupCompleted(any(), any())).thenReturn(successful); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(store); + ArtifactDeletionCoordinator coordinator = coordinator( + mock(ConversionJobRepository.class), + new InMemoryArtifactStore(), + store, + metrics, + 2 + ); + + assertEquals(2, coordinator.retryPendingWork()); + assertEquals(1L, metrics.failedAttempts()); + assertEquals(1L, metrics.completedAttempts()); + + when(store.pendingReceipts()).thenReturn(List.of()); + coordinator.retryPendingAfterDelay(); + assertEquals(0, coordinator.retryPendingWork()); + } + + @Test + void existingReceiptMustMatchTheActiveLifecycleAndCompletedReceiptIsNoop() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt existing = ledger.request( + UUID.randomUUID(), + OTHER_TENANT_ID, + JOB_ID, + sha256(ORIGINAL_ARTIFACT), + "cleanup-v1:conflict", + Instant.now() + ); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + 100 + ); + + assertThrows(IllegalStateException.class, () -> coordinator.deleteForTenant(JOB_ID, TENANT_ID)); + assertSame(existing, ledger.findByJobId(JOB_ID).orElseThrow()); + + ArtifactDeletionLedger completedLedger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt completed = request( + completedLedger, + UUID.randomUUID(), + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + Instant.now() + ); + completed = completedLedger.markMetadataTombstoned(completed.jobId(), completed.requestedAt()); + completed = completedLedger.markCleanupPending(completed.jobId(), completed.stateChangedAt()); + completed = completedLedger.markCleanupCompleted(completed.jobId(), completed.stateChangedAt()); + ArtifactDeletionMetrics completedMetrics = new ArtifactDeletionMetrics(completedLedger); + coordinator( + new InMemoryConversionJobRepository(), + new InMemoryArtifactStore(), + completedLedger, + completedMetrics, + 100 + ).resumeReceipt(completed); + assertEquals(0L, completedMetrics.completedAttempts()); + } + + @Test + void globalCompatibilityPathUsesReceiptsForKnownJobsAndDeletesMissingMetadataIdempotently() { + InMemoryConversionJobRepository repository = repositoryWithJob(); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + 100 + ); + + coordinator.deleteGlobally(JOB_ID); + UUID missing = UUID.randomUUID(); + coordinator.deleteGlobally(missing); + + assertTrue(repository.findById(JOB_ID).isEmpty()); + assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + ledger.findByJobId(JOB_ID).orElseThrow().state() + ); + assertTrue(ledger.findByJobId(missing).isEmpty()); + assertThrows(NullPointerException.class, () -> coordinator.deleteGlobally(null)); + } + + @Test + void constructorAndMissingReceiptFailClosed() { + ArtifactDeletionReceiptStore store = mock(ArtifactDeletionReceiptStore.class); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(store); + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ArtifactStore artifactStore = mock(ArtifactStore.class); + + assertThrows(NullPointerException.class, () -> new ArtifactDeletionMetrics(null)); + assertThrows(NullPointerException.class, () -> coordinator(null, artifactStore, store, metrics, 1)); + assertThrows(NullPointerException.class, () -> coordinator(repository, null, store, metrics, 1)); + assertThrows(NullPointerException.class, () -> coordinator(repository, artifactStore, null, metrics, 1)); + assertThrows(NullPointerException.class, () -> coordinator(repository, artifactStore, store, null, 1)); + assertThrows(IllegalArgumentException.class, () -> coordinator(repository, artifactStore, store, metrics, 0)); + + ArtifactDeletionReceipt candidate = receiptSnapshot(JOB_ID); + when(store.findByJobId(JOB_ID)).thenReturn(Optional.empty()); + ArtifactDeletionCoordinator coordinator = coordinator(repository, artifactStore, store, metrics, 1); + assertThrows(IllegalStateException.class, () -> coordinator.resumeReceipt(candidate)); + assertThrows(NullPointerException.class, () -> coordinator.resumeReceipt(null)); + } + + @Test + void sha256ProviderAbsenceFailsBeforeReceiptCreation() { + synchronized (SecurityProviderTestSupport.SECURITY_PROVIDERS_LOCK) { + List providers = SecurityProviderTestSupport.sha256ProviderPositions(); + try { + for (ProviderPosition provider : providers) { + Security.removeProvider(provider.provider().getName()); + } + InMemoryConversionJobRepository repository = repositoryWithJob(); + InMemoryArtifactStore artifactStore = new InMemoryArtifactStore(); + artifactStore.putPdf(JOB_ID, ORIGINAL_ARTIFACT); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator coordinator = coordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + 100 + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> coordinator.deleteForTenant(JOB_ID, TENANT_ID) + ); + assertEquals("SHA-256 digest unavailable", exception.getMessage()); + assertTrue(ledger.findByJobId(JOB_ID).isEmpty()); + } finally { + for (ProviderPosition provider : providers) { + Security.insertProviderAt(provider.provider(), provider.position()); + } + } + } + } + + private static ArtifactDeletionCoordinator coordinator( + ConversionJobRepository repository, + ArtifactStore artifactStore, + ArtifactDeletionReceiptStore receiptStore, + ArtifactDeletionMetrics metrics, + int maxReceiptsPerRun + ) { + return new ArtifactDeletionCoordinator( + repository, + artifactStore, + receiptStore, + metrics, + maxReceiptsPerRun + ); + } + + private static InMemoryConversionJobRepository repositoryWithJob() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + repository.save(job()); + return repository; + } + + private static ConversionJob job() { + return new ConversionJob( + JOB_ID, + TENANT_ID, + "subject-north", + "report.pdf", + "application/pdf", + "job-content-hash", + ORIGINAL_ARTIFACT.length, + 3 + ); + } + + private static ArtifactDeletionReceipt request( + ArtifactDeletionLedger ledger, + UUID jobId, + String checksum, + Instant requestedAt + ) { + return ledger.request( + UUID.randomUUID(), + TENANT_ID, + jobId, + checksum, + "cleanup-v1:" + jobId.toString().replace("-", ""), + requestedAt + ); + } + + private static ArtifactDeletionReceipt receiptSnapshot(UUID jobId) { + Instant requestedAt = Instant.parse("2026-08-06T00:00:00Z"); + return new ArtifactDeletionReceipt( + UUID.randomUUID(), + TENANT_ID, + jobId, + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + "cleanup-v1:snapshot", + requestedAt, + requestedAt, + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + 0, + null, + null, + null + ); + } + + private static ConversionJobRepository repositoryThatWritesOnDelete( + InMemoryArtifactStore artifactStore, + byte[] replacement + ) { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + when(repository.findByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(Optional.of(job())); + when(repository.deleteByTenantAndId(TENANT_ID, JOB_ID)).thenAnswer(invocation -> { + artifactStore.putPdf(JOB_ID, replacement); + return true; + }); + return repository; + } + + private static ArtifactStore throwingStore(boolean failRead, boolean failDelete) { + return new ArtifactStore() { + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + throw new AssertionError("not used"); + } + + @Override + public Optional getPdf(UUID docId) { + if (failRead) { + throw new IllegalStateException("artifact read failed"); + } + return Optional.empty(); + } + + @Override + public void deletePdf(UUID docId) { + if (failDelete) { + throw new IllegalStateException("artifact delete failed"); + } + } + }; + } + + private static ArtifactStore nullReturningStore() { + return new ArtifactStore() { + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + } + + @Override + public Optional getPdf(UUID docId) { + return null; + } + + @Override + public void deletePdf(UUID docId) { + } + }; + } + + private static String sha256(byte[] bytes) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + return java.util.HexFormat.of().formatHex(digest.digest(bytes)); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static final class FailingOnceArtifactStore implements ArtifactStore { + private final InMemoryArtifactStore delegate = new InMemoryArtifactStore(); + private final AtomicBoolean failNextDelete = new AtomicBoolean(true); + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + delegate.putPdf(docId, pdfBytes); + } + + @Override + public Optional getPdf(UUID docId) { + return delegate.getPdf(docId); + } + + @Override + public void deletePdf(UUID docId) { + if (failNextDelete.compareAndSet(true, false)) { + throw new IllegalStateException("artifact delete failed"); + } + delegate.deletePdf(docId); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionGlobalRetryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionGlobalRetryTest.java new file mode 100644 index 000000000..2fb7d3cc5 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionGlobalRetryTest.java @@ -0,0 +1,87 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Verifies idempotent recovery through the legacy global deletion boundary. + */ +class ArtifactDeletionGlobalRetryTest { + + @Test + void repeatedGlobalDeleteResumesFailedCleanupAfterMetadataIsTombstoned() { + UUID jobId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + repository.save(new ConversionJob( + jobId, + "tenant-north", + "subject-north", + "report.pdf", + "application/pdf", + "content-hash", + 3L, + 3 + )); + FailingOnceDeleteStore artifactStore = new FailingOnceDeleteStore(); + artifactStore.putPdf(jobId, new byte[] {1, 2, 3}); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + 10 + ); + + coordinator.deleteGlobally(jobId); + + assertTrue(repository.findById(jobId).isEmpty()); + assertTrue(artifactStore.getPdf(jobId).isPresent()); + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + ledger.findByJobId(jobId).orElseThrow().state() + ); + + coordinator.deleteGlobally(jobId); + + assertTrue(artifactStore.getPdf(jobId).isEmpty()); + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + ledger.findByJobId(jobId).orElseThrow().state() + ); + } + + private static final class FailingOnceDeleteStore implements ArtifactStore { + private final InMemoryArtifactStore delegate = new InMemoryArtifactStore(); + private final AtomicBoolean failNextDelete = new AtomicBoolean(true); + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + delegate.putPdf(docId, pdfBytes); + } + + @Override + public Optional getPdf(UUID docId) { + return delegate.getPdf(docId); + } + + @Override + public void deletePdf(UUID docId) { + if (failNextDelete.compareAndSet(true, false)) { + throw new IllegalStateException("controlled delete failure"); + } + delegate.deletePdf(docId); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryBoundTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryBoundTest.java new file mode 100644 index 000000000..2c7043cc3 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryBoundTest.java @@ -0,0 +1,71 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Verifies that one recovery pass cannot process beyond its configured bound. + */ +class ArtifactDeletionRecoveryBoundTest { + + @Test + void recoverySelectsOnlyTheConfiguredNumberOfPendingReceipts() { + ArtifactDeletionReceipt first = pendingReceipt( + UUID.fromString("00000000-0000-0000-0000-000000000001") + ); + ArtifactDeletionReceipt second = pendingReceipt( + UUID.fromString("00000000-0000-0000-0000-000000000002") + ); + ArtifactDeletionReceipt third = pendingReceipt( + UUID.fromString("00000000-0000-0000-0000-000000000003") + ); + ArtifactDeletionReceiptStore store = mock(ArtifactDeletionReceiptStore.class); + when(store.pendingReceipts()).thenReturn(List.of(first, second, third)); + when(store.findByJobId(first.jobId())).thenReturn(Optional.empty()); + when(store.findByJobId(second.jobId())).thenReturn(Optional.of(second)); + when(store.markCleanupCompleted(any(), any())).thenReturn(second); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(store); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + mock(ConversionJobRepository.class), + new InMemoryArtifactStore(), + store, + metrics, + new ArtifactLifecycleLockRegistry(8), + 2 + ); + + assertEquals(2, coordinator.retryPendingWork()); + assertEquals(1L, metrics.failedAttempts()); + assertEquals(1L, metrics.completedAttempts()); + verify(store, never()).findByJobId(third.jobId()); + } + + private static ArtifactDeletionReceipt pendingReceipt(UUID jobId) { + Instant now = Instant.parse("2026-08-06T00:00:00Z"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ledger.request( + UUID.randomUUID(), + "tenant-north", + jobId, + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + "cleanup-v1:" + jobId.toString().replace("-", ""), + now + ); + ledger.markMetadataTombstoned(jobId, now); + return ledger.markCleanupPending(jobId, now); + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java new file mode 100644 index 000000000..c09be637a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java @@ -0,0 +1,101 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +/** + * Verifies fixed-memory process-local serialization for artifact lifecycles. + */ +class ArtifactLifecycleLockRegistryTest { + + @Test + void sameJobCannotEnterTwoArtifactLifecyclesConcurrently() throws Exception { + ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(8); + UUID jobId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + CountDownLatch firstEntered = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + CountDownLatch secondEntered = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> registry.withJobLock(jobId, () -> { + firstEntered.countDown(); + await(releaseFirst); + return "first"; + })); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + + Future second = executor.submit(() -> { + secondStarted.countDown(); + return registry.withJobLock(jobId, () -> { + secondEntered.countDown(); + return "second"; + }); + }); + + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertFalse(secondEntered.await(200, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + + assertEquals("first", first.get(5, TimeUnit.SECONDS)); + assertEquals("second", second.get(5, TimeUnit.SECONDS)); + assertTrue(secondEntered.await(5, TimeUnit.SECONDS)); + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void actionFailureReleasesStripeForFollowingWork() { + ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(1); + UUID jobId = UUID.randomUUID(); + IllegalStateException expected = new IllegalStateException("controlled failure"); + + IllegalStateException actual = assertThrows( + IllegalStateException.class, + () -> registry.withJobLock(jobId, () -> { + throw expected; + }) + ); + + assertSame(expected, actual); + assertEquals("recovered", registry.withJobLock(jobId, () -> "recovered")); + } + + @Test + void invalidConstructionAndNullInputsFailFast() { + assertThrows(IllegalArgumentException.class, () -> new ArtifactLifecycleLockRegistry(0)); + ArtifactLifecycleLockRegistry defaultRegistry = new ArtifactLifecycleLockRegistry(); + ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(2); + + assertEquals("default", defaultRegistry.withJobLock(UUID.randomUUID(), () -> "default")); + assertThrows(NullPointerException.class, () -> registry.withJobLock(null, () -> "unused")); + assertThrows(NullPointerException.class, () -> registry.withJobLock(UUID.randomUUID(), null)); + assertSame(ArtifactLifecycleLockRegistry.shared(), ArtifactLifecycleLockRegistry.shared()); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("latch timeout"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted", exception); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleSerializationTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleSerializationTest.java new file mode 100644 index 000000000..6b53b24d4 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleSerializationTest.java @@ -0,0 +1,129 @@ +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.assertTrue; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.artifact.LifecycleFencedArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Proves that artifact publication and deletion use one per-job lifecycle fence. + */ +class ArtifactLifecycleSerializationTest { + + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(10); + + @Test + void deletionWaitsForInFlightPublicationThenDeletesThePublishedGeneration() { + assertTimeoutPreemptively(TEST_TIMEOUT, () -> { + UUID jobId = UUID.randomUUID(); + ArtifactLifecycleLockRegistry locks = new ArtifactLifecycleLockRegistry(); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + BlockingPutStore delegate = new BlockingPutStore(); + ArtifactStore artifactStore = new LifecycleFencedArtifactStore( + delegate, + ledger, + locks + ); + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + repository.save(new ConversionJob( + jobId, + "tenant-a", + "subject-a", + "report.pdf", + "application/pdf", + "serialization-hash", + 3L, + 3 + )); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + locks, + 100 + ); + CountDownLatch deletionStarted = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future publication = executor.submit( + () -> artifactStore.putPdf(jobId, new byte[] {1, 2, 3}) + ); + assertTrue(delegate.putEntered.await(2, TimeUnit.SECONDS)); + + Future deletion = executor.submit(() -> { + deletionStarted.countDown(); + return coordinator.deleteForTenant(jobId, "tenant-a"); + }); + assertTrue(deletionStarted.await(2, TimeUnit.SECONDS)); + assertFalse(deletion.isDone()); + + delegate.releasePut.countDown(); + publication.get(2, TimeUnit.SECONDS); + assertTrue(deletion.get(2, TimeUnit.SECONDS)); + } finally { + delegate.releasePut.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS)); + } + + assertTrue(repository.findById(jobId).isEmpty()); + assertTrue(artifactStore.getPdf(jobId).isEmpty()); + assertEquals( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + ledger.findByJobId(jobId).orElseThrow().state() + ); + }); + } + + private static final class BlockingPutStore implements ArtifactStore { + private final InMemoryArtifactStore delegate = new InMemoryArtifactStore(); + private final CountDownLatch putEntered = new CountDownLatch(1); + private final CountDownLatch releasePut = new CountDownLatch(1); + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + putEntered.countDown(); + await(releasePut); + delegate.putPdf(docId, pdfBytes); + } + + @Override + public Optional getPdf(UUID docId) { + return delegate.getPdf(docId); + } + + @Override + public void deletePdf(UUID docId) { + delegate.deletePdf(docId); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("publication release was not signalled"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("publication was interrupted", exception); + } + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DurableDocumentDeletionServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DurableDocumentDeletionServiceTest.java new file mode 100644 index 000000000..95aa96c23 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DurableDocumentDeletionServiceTest.java @@ -0,0 +1,129 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.lifecycle.ArtifactDeletionCoordinator; +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Verifies that durable deletion is isolated in a primary service decorator and + * every non-deletion operation preserves the existing conversion-service contract. + */ +class DurableDocumentDeletionServiceTest { + + @Test + void constructorRejectsMissingCollaborators() { + DefaultDocumentConversionService delegate = mock(DefaultDocumentConversionService.class); + ArtifactDeletionCoordinator coordinator = mock(ArtifactDeletionCoordinator.class); + + assertThrows( + NullPointerException.class, + () -> new DurableDocumentDeletionService(null, coordinator) + ); + assertThrows( + NullPointerException.class, + () -> new DurableDocumentDeletionService(delegate, null) + ); + } + + @Test + void nonDeletionOperationsDelegateWithoutChangingArgumentsOrResults() { + DefaultDocumentConversionService delegate = mock(DefaultDocumentConversionService.class); + ArtifactDeletionCoordinator coordinator = mock(ArtifactDeletionCoordinator.class); + DurableDocumentDeletionService service = new DurableDocumentDeletionService( + delegate, + coordinator + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "report.docx", + "application/octet-stream", + new byte[] {1, 2, 3} + ); + PolicyOverrideRequest overrideRequest = PolicyOverrideRequest.none(); + TenantContext tenantContext = new TenantContext("tenant-a", "subject-a", Set.of()); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + UUID thirdId = UUID.randomUUID(); + ConversionJob job = mock(ConversionJob.class); + List tenantJobs = List.of(job); + List allJobs = List.of(job, mock(ConversionJob.class)); + when(delegate.submit(file)).thenReturn(firstId); + when(delegate.submit(file, overrideRequest)).thenReturn(secondId); + when(delegate.submit(file, overrideRequest, tenantContext)).thenReturn(thirdId); + when(delegate.getJob(firstId)).thenReturn(Optional.of(job)); + when(delegate.retryDeadLettered(firstId, "operator")).thenReturn(RetryDeadLetterResult.ACCEPTED); + when(delegate.retryDeadLettered(firstId, tenantContext, "operator")) + .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(delegate.getJobsForTenant(tenantContext)).thenReturn(tenantJobs); + when(delegate.getAllJobs()).thenReturn(allJobs); + + assertEquals(firstId, service.submit(file)); + assertEquals(secondId, service.submit(file, overrideRequest)); + assertEquals(thirdId, service.submit(file, overrideRequest, tenantContext)); + assertSame(job, service.getJob(firstId).orElseThrow()); + assertEquals( + RetryDeadLetterResult.ACCEPTED, + service.retryDeadLettered(firstId, "operator") + ); + assertEquals( + RetryDeadLetterResult.NOT_ELIGIBLE, + service.retryDeadLettered(firstId, tenantContext, "operator") + ); + assertSame(tenantJobs, service.getJobsForTenant(tenantContext)); + assertSame(allJobs, service.getAllJobs()); + verify(coordinator, never()).deleteGlobally(firstId); + } + + @Test + void tenantDeletionFailsClosedWithoutContextAndOtherwiseUsesCoordinator() { + DefaultDocumentConversionService delegate = mock(DefaultDocumentConversionService.class); + ArtifactDeletionCoordinator coordinator = mock(ArtifactDeletionCoordinator.class); + DurableDocumentDeletionService service = new DurableDocumentDeletionService( + delegate, + coordinator + ); + UUID jobId = UUID.randomUUID(); + TenantContext tenantContext = new TenantContext("tenant-a", "subject-a", Set.of()); + when(coordinator.deleteForTenant(jobId, "tenant-a")).thenReturn(true); + + assertFalse(service.deleteJob(jobId, null)); + assertTrue(service.deleteJob(jobId, tenantContext)); + + verify(coordinator).deleteForTenant(jobId, "tenant-a"); + verify(delegate, never()).deleteJob(jobId, tenantContext); + } + + @Test + void globalCompatibilityDeletionUsesCoordinator() { + DefaultDocumentConversionService delegate = mock(DefaultDocumentConversionService.class); + ArtifactDeletionCoordinator coordinator = mock(ArtifactDeletionCoordinator.class); + DurableDocumentDeletionService service = new DurableDocumentDeletionService( + delegate, + coordinator + ); + UUID jobId = UUID.randomUUID(); + + service.deleteJob(jobId); + + verify(coordinator).deleteGlobally(jobId); + verify(delegate, never()).deleteJob(jobId); + } +} From 758de22e6ca6612c59f8124d5f79b6a4afe21d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:02:17 +0900 Subject: [PATCH 114/178] chore(stack): inherit all-PR CI from authoritative parent --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b217d9a75..52499d2b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,8 @@ name: CI on: push: branches: [main] - pull_request: {} + pull_request: + branches: [main] permissions: contents: read From 19fbfc4b938530ff70fe152ab294813f1b3a1e8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:02:30 +0900 Subject: [PATCH 115/178] chore(stack): remove duplicated parent CI contract --- scripts/test_ci_workflow_stack_coverage.py | 24 ---------------------- 1 file changed, 24 deletions(-) delete mode 100644 scripts/test_ci_workflow_stack_coverage.py diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py deleted file mode 100644 index 8a3af8b7e..000000000 --- a/scripts/test_ci_workflow_stack_coverage.py +++ /dev/null @@ -1,24 +0,0 @@ -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 From 7ae97afea69c963aba5956d0682431fe233961db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:13:47 +0900 Subject: [PATCH 116/178] test(lifecycle): align receipt assertions with integrated contract --- .../lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java index 13a8e074a..423f15201 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java @@ -109,7 +109,7 @@ void failedReceiptRejectsLastAttemptTimeThatDiffersFromStateTime() { ) ); - assertEquals("Failed receipt has inconsistent failure evidence.", exception.getMessage()); + assertEquals("failed receipt fields are inconsistent", exception.getMessage()); } @Test @@ -143,7 +143,7 @@ void transitionRejectsAChangedImmutableIdentity() throws Exception { ); assertEquals( - "Artifact deletion receipt transition changed immutable identity.", + "artifact deletion receipt conflicts with an existing lifecycle", invocation.getCause().getMessage() ); assertEquals(TENANT_ID, ledger.findByJobId(JOB_ID).orElseThrow().tenantId()); From b08fde80de86bdb892d7067972cf0823b2988806 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:15:54 +0900 Subject: [PATCH 117/178] test(security): preserve fail-closed tenant delete default --- .../ServiceInterfaceDefaultMethodsTest.java | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java b/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java index 8fc7faadd..4a4f98d7c 100644 --- a/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java +++ b/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Optional; import java.util.UUID; @@ -105,18 +104,9 @@ public Iterable getAllJobs() { } @Test - void documentConversionServiceTenantDeleteDefaultFiltersByTenantBeforeDeleting() { + void documentConversionServiceTenantDeleteDefaultFailsClosedWithoutReadingOrDeleting() { UUID jobId = UUID.randomUUID(); - ConversionJob job = new ConversionJob( - jobId, - "tenant-a", - "user-1", - "report.docx", - "application/octet-stream", - "hash-default-delete", - 1L, - 3 - ); + AtomicReference lookedUpJobId = new AtomicReference<>(); AtomicReference deletedJobId = new AtomicReference<>(); DocumentConversionService service = new DocumentConversionService() { @Override @@ -126,7 +116,8 @@ public UUID submit(MultipartFile file) { @Override public Optional getJob(UUID requestedJobId) { - return jobId.equals(requestedJobId) ? Optional.of(job) : Optional.empty(); + lookedUpJobId.set(requestedJobId); + return Optional.empty(); } @Override @@ -146,16 +137,11 @@ public Iterable getAllJobs() { }; assertFalse(service.deleteJob( - jobId, - new com.clearfolio.viewer.auth.TenantContext("tenant-b", "user-2", java.util.Set.of()) - )); - assertEquals(null, deletedJobId.get()); - - assertTrue(service.deleteJob( jobId, new com.clearfolio.viewer.auth.TenantContext("tenant-a", "user-1", java.util.Set.of()) )); - assertEquals(jobId, deletedJobId.get()); + assertEquals(null, lookedUpJobId.get()); + assertEquals(null, deletedJobId.get()); } @Test From 74830788852fdda160980271bbe1bd77a488ac8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:16:10 +0900 Subject: [PATCH 118/178] test(security): assert scoped delete default performs no read --- .../viewer/service/DocumentConversionServiceCoverageTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java index 0784ca943..23cfb1404 100644 --- a/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java @@ -20,7 +20,7 @@ class DocumentConversionServiceCoverageTest { @Test - void tenantScopedDeleteRejectsMissingContextAndMissingJobBeforeMutation() { + void tenantScopedDeleteRejectsEveryContextBeforeReadingOrMutating() { UUID jobId = UUID.randomUUID(); AtomicInteger lookupCount = new AtomicInteger(); AtomicInteger deleteCount = new AtomicInteger(); @@ -60,7 +60,7 @@ public Iterable getAllJobs() { jobId, new TenantContext("tenant-a", "subject-a", Set.of()) )); - assertEquals(1, lookupCount.get()); + assertEquals(0, lookupCount.get()); assertEquals(0, deleteCount.get()); } } From 5f7df14239c746070825f7323700a5491d4ecaf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:18:34 +0900 Subject: [PATCH 119/178] test(lifecycle): make write-failure fixture restart-valid --- .../lifecycle/ArtifactDeletionLedgerCoverageTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java index 32163d006..4dd550594 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java @@ -235,9 +235,12 @@ void loadAndWriteIoFailuresAreReportedWithoutPartialSuccess() throws IOException ).getMessage() ); - Path parentFile = tempDirectory.resolve("parent_file"); - Files.writeString(parentFile, "not a directory", StandardCharsets.UTF_8); - ArtifactDeletionLedger unwritable = new ArtifactDeletionLedger(parentFile.resolve("ledger.log")); + Path parentDirectory = tempDirectory.resolve("write_parent"); + Files.createDirectory(parentDirectory); + Path ledgerPath = parentDirectory.resolve("ledger.log"); + ArtifactDeletionLedger unwritable = new ArtifactDeletionLedger(ledgerPath); + Files.delete(parentDirectory); + Files.writeString(parentDirectory, "not a directory", StandardCharsets.UTF_8); assertEquals( "artifact deletion ledger cannot be written", assertThrows( From 558dcbfc6786be104676107f5a3d0060aa3c51d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:25:14 +0900 Subject: [PATCH 120/178] test(coverage): exercise fail-closed tenant listing default --- ...tConversionServiceDefaultCoverageTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceDefaultCoverageTest.java diff --git a/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceDefaultCoverageTest.java b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceDefaultCoverageTest.java new file mode 100644 index 000000000..db20d9ac3 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceDefaultCoverageTest.java @@ -0,0 +1,54 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Covers compatibility defaults that intentionally return no tenant data until + * a concrete implementation supplies a persistence-scoped query. + */ +class DocumentConversionServiceDefaultCoverageTest { + + @Test + void tenantListingDefaultReturnsNoJobs() { + DocumentConversionService service = new CompatibilityOnlyService(); + + assertFalse(service.getJobsForTenant( + new TenantContext("tenant-a", "subject-a", java.util.Set.of()) + ).iterator().hasNext()); + } + + private static final class CompatibilityOnlyService implements DocumentConversionService { + @Override + public UUID submit(MultipartFile file) { + return UUID.randomUUID(); + } + + @Override + public Optional getJob(UUID jobId) { + return Optional.empty(); + } + + @Override + public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { + return RetryDeadLetterResult.NOT_FOUND; + } + + @Override + public void deleteJob(UUID jobId) { + } + + @Override + public Iterable getAllJobs() { + return java.util.List.of(); + } + } +} From 00d8a9435565f46bcd64198e344f6a3ed0dce714 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:25:52 +0900 Subject: [PATCH 121/178] test(lifecycle): cover fail-closed replay branch matrix --- ...ifactDeletionLedgerBranchCoverageTest.java | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java new file mode 100644 index 000000000..5d4f66763 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +/** + * Exercises defensive replay branches whose invalid combinations cannot be + * produced by the validated public receipt constructor. + */ +class ArtifactDeletionLedgerBranchCoverageTest { + + private static final Instant START = Instant.parse("2026-08-06T12:00:00Z"); + + @Test + void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { + Method validator = privateMethod( + "validateReplayTransition", + ArtifactDeletionReceipt.class, + ArtifactDeletionReceipt.class + ); + + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), + receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START.plusSeconds(1), 1, START) + ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 1, START) + ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 0, null) + ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 1, START) + ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, START.plusSeconds(1), 2, START.plusSeconds(1)) + ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null) + ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, START, 1, START), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 1, START) + ); + } + + @Test + void replayTransitionAcceptsBothPendingOutcomesAndFailedRetry() throws Exception { + Method validator = privateMethod( + "validateReplayTransition", + ArtifactDeletionReceipt.class, + ArtifactDeletionReceipt.class + ); + + validator.invoke( + null, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 0, null) + ); + validator.invoke( + null, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), + receipt( + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + START.plusSeconds(1), + 1, + START.plusSeconds(1) + ) + ); + validator.invoke( + null, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, START, 1, START), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 1, START) + ); + } + + @Test + void privateCodecHelpersCoverNullBlankAndPresentValues() throws Exception { + Method decodeRequired = privateMethod("decodeRequired", String.class); + Method decodeOptional = privateMethod("decodeOptional", String.class); + Method parseOptionalInstant = privateMethod("optionalInstant", String.class); + Method formatOptionalInstant = privateMethod("optionalInstant", Instant.class); + Method pathOf = privateMethod("pathOf", String.class); + String encodedTenant = encode("tenant-edge"); + String encodedBlank = encode(" "); + + assertEquals("tenant-edge", decodeRequired.invoke(null, encodedTenant)); + assertThrows(InvocationTargetException.class, () -> decodeRequired.invoke(null, "-")); + assertThrows(InvocationTargetException.class, () -> decodeRequired.invoke(null, encodedBlank)); + assertNull(decodeOptional.invoke(null, "-")); + assertEquals("tenant-edge", decodeOptional.invoke(null, encodedTenant)); + assertNull(parseOptionalInstant.invoke(null, "-")); + assertEquals(START, parseOptionalInstant.invoke(null, START.toString())); + assertEquals("-", formatOptionalInstant.invoke(null, new Object[] {null})); + assertEquals(START.toString(), formatOptionalInstant.invoke(null, START)); + assertNull(pathOf.invoke(null, new Object[] {null})); + assertNull(pathOf.invoke(null, " ")); + assertEquals(Path.of("ledger.log"), pathOf.invoke(null, " ledger.log ")); + } + + private static Method privateMethod(String name, Class... parameterTypes) throws NoSuchMethodException { + Method method = ArtifactDeletionLedger.class.getDeclaredMethod(name, parameterTypes); + method.setAccessible(true); + return method; + } + + private static void assertInvalidTransition( + Method validator, + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed + ) { + assertThrows(InvocationTargetException.class, () -> validator.invoke(null, current, replayed)); + } + + private static ArtifactDeletionReceipt receipt( + ArtifactDeletionState state, + Instant stateChangedAt, + int attempts, + Instant lastAttemptAt + ) { + ArtifactDeletionReceipt receipt = mock(ArtifactDeletionReceipt.class); + when(receipt.state()).thenReturn(state); + when(receipt.stateChangedAt()).thenReturn(stateChangedAt); + when(receipt.attemptCount()).thenReturn(attempts); + when(receipt.lastAttemptAt()).thenReturn(lastAttemptAt); + return receipt; + } + + private static String encode(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } +} From 2173a0e6365cbf41b3bce30bc14dafc6ca1a30fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:26:06 +0900 Subject: [PATCH 122/178] test(lifecycle): cover failure state without attempt evidence --- ...factDeletionReceiptBranchCoverageTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java new file mode 100644 index 000000000..284ee5dea --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java @@ -0,0 +1,41 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Covers fail-closed receipt-state combinations at the boundaries of attempt + * and failure evidence. + */ +class ArtifactDeletionReceiptBranchCoverageTest { + + @Test + void failedReceiptWithoutAnyAttemptEvidenceIsRejected() { + Instant requestedAt = Instant.parse("2026-08-06T12:00:00Z"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceipt( + UUID.fromString("11111111-2222-3333-4444-555555555555"), + "tenant-edge", + UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "audit-v1:0123456789abcdef0123456789abcdef", + requestedAt, + requestedAt.plusSeconds(1), + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + 0, + null, + null, + "artifact_store_delete_failed" + ) + ); + + assertEquals("failed receipt fields are inconsistent", exception.getMessage()); + } +} From 4a831ad5aef00e12b67287c048b547e1a2e252a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:26:24 +0900 Subject: [PATCH 123/178] test(lifecycle): cover checksum-only receipt conflict --- ...DeletionCoordinatorBranchCoverageTest.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java new file mode 100644 index 000000000..4ab0759db --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java @@ -0,0 +1,64 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Covers independent tenant and artifact-generation conflict checks for an + * already active deletion lifecycle. + */ +class ArtifactDeletionCoordinatorBranchCoverageTest { + + @Test + void existingTenantReceiptRejectsAChangedArtifactChecksum() throws Exception { + UUID jobId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + String originalChecksum = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + String changedChecksum = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ledger.request( + UUID.fromString("11111111-2222-3333-4444-555555555555"), + "tenant-edge", + jobId, + originalChecksum, + "audit-v1:0123456789abcdef0123456789abcdef", + Instant.parse("2026-08-06T12:00:00Z") + ); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + mock(ConversionJobRepository.class), + mock(ArtifactStore.class), + ledger, + new ArtifactDeletionMetrics(ledger), + 10 + ); + Method requestReceipt = ArtifactDeletionCoordinator.class.getDeclaredMethod( + "requestReceipt", + String.class, + UUID.class, + String.class + ); + requestReceipt.setAccessible(true); + + InvocationTargetException invocation = assertThrows( + InvocationTargetException.class, + () -> requestReceipt.invoke(coordinator, "tenant-edge", jobId, changedChecksum) + ); + + assertEquals( + "artifact deletion receipt conflicts with the active lifecycle", + invocation.getCause().getMessage() + ); + } +} From 4dd0c706c98ff85a0af9fed57ac3c165724d25d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:30:20 +0900 Subject: [PATCH 124/178] test(lifecycle): close remaining replay switch branches --- ...ifactDeletionLedgerBranchCoverageTest.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java index 5d4f66763..745085478 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java @@ -31,6 +31,11 @@ void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { ArtifactDeletionReceipt.class ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null) + ); assertInvalidTransition( validator, receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), @@ -66,16 +71,31 @@ void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, START, 1, START), receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 1, START) ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null) + ); } @Test - void replayTransitionAcceptsBothPendingOutcomesAndFailedRetry() throws Exception { + void replayTransitionAcceptsRequestedPendingOutcomesAndFailedRetry() throws Exception { Method validator = privateMethod( "validateReplayTransition", ArtifactDeletionReceipt.class, ArtifactDeletionReceipt.class ); + validator.invoke( + null, + receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), + receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START.plusSeconds(1), 0, null) + ); + validator.invoke( + null, + receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START, 0, null), + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null) + ); validator.invoke( null, receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), From a37c9c5cc7d49034f8a3e6215bfb950ab5ed1a4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:30:46 +0900 Subject: [PATCH 125/178] test(lifecycle): close remaining failed-receipt branches --- ...factDeletionReceiptBranchCoverageTest.java | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java index 284ee5dea..3e587147b 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java @@ -14,28 +14,67 @@ */ class ArtifactDeletionReceiptBranchCoverageTest { + private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T12:00:00Z"); + @Test void failedReceiptWithoutAnyAttemptEvidenceIsRejected() { - Instant requestedAt = Instant.parse("2026-08-06T12:00:00Z"); + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> failedReceipt(0, null, REQUESTED_AT.plusSeconds(1), "artifact_store_delete_failed") + ); + assertEquals("failed receipt fields are inconsistent", exception.getMessage()); + } + + @Test + void failedReceiptRequiresAttemptTimeToEqualItsStateTime() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new ArtifactDeletionReceipt( - UUID.fromString("11111111-2222-3333-4444-555555555555"), - "tenant-edge", - UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "audit-v1:0123456789abcdef0123456789abcdef", - requestedAt, - requestedAt.plusSeconds(1), - ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, - 0, - null, - null, + () -> failedReceipt( + 1, + REQUESTED_AT.plusSeconds(1), + REQUESTED_AT.plusSeconds(2), "artifact_store_delete_failed" ) ); assertEquals("failed receipt fields are inconsistent", exception.getMessage()); } + + @Test + void failedReceiptAcceptsCompleteMatchingAttemptEvidence() { + Instant failedAt = REQUESTED_AT.plusSeconds(2); + + ArtifactDeletionReceipt receipt = failedReceipt( + 1, + failedAt, + failedAt, + "artifact_store_delete_failed" + ); + + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, receipt.state()); + assertEquals(failedAt, receipt.lastAttemptAt()); + } + + private static ArtifactDeletionReceipt failedReceipt( + int attemptCount, + Instant lastAttemptAt, + Instant stateChangedAt, + String failureCode + ) { + return new ArtifactDeletionReceipt( + UUID.fromString("11111111-2222-3333-4444-555555555555"), + "tenant-edge", + UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "audit-v1:0123456789abcdef0123456789abcdef", + REQUESTED_AT, + stateChangedAt, + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + attemptCount, + lastAttemptAt, + null, + failureCode + ); + } } From 0739a047d1daa8195de27a32b65071e0dcdf083b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:33:34 +0900 Subject: [PATCH 126/178] test(lifecycle): cover completed receipt failure evidence --- ...factDeletionReceiptBranchCoverageTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java index 3e587147b..10d33d1f2 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java @@ -56,6 +56,31 @@ void failedReceiptAcceptsCompleteMatchingAttemptEvidence() { assertEquals(failedAt, receipt.lastAttemptAt()); } + @Test + void completedReceiptRejectsFailureEvidence() { + Instant completedAt = REQUESTED_AT.plusSeconds(2); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceipt( + UUID.fromString("11111111-2222-3333-4444-555555555555"), + "tenant-edge", + UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "audit-v1:0123456789abcdef0123456789abcdef", + REQUESTED_AT, + completedAt, + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + 0, + null, + completedAt, + "artifact_store_delete_failed" + ) + ); + + assertEquals("completed receipt fields are inconsistent", exception.getMessage()); + } + private static ArtifactDeletionReceipt failedReceipt( int attemptCount, Instant lastAttemptAt, From 6daff7938fa6b4edb05fde29301954fcad22e5f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:34:16 +0900 Subject: [PATCH 127/178] test(lifecycle): require stable cleanup failure code --- .../ArtifactDeletionReceiptBranchCoverageTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java index 10d33d1f2..0b4b5ce00 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java @@ -41,6 +41,18 @@ void failedReceiptRequiresAttemptTimeToEqualItsStateTime() { assertEquals("failed receipt fields are inconsistent", exception.getMessage()); } + @Test + void failedReceiptRequiresAStableFailureCode() { + Instant failedAt = REQUESTED_AT.plusSeconds(2); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> failedReceipt(1, failedAt, failedAt, null) + ); + + assertEquals("failed receipt fields are inconsistent", exception.getMessage()); + } + @Test void failedReceiptAcceptsCompleteMatchingAttemptEvidence() { Instant failedAt = REQUESTED_AT.plusSeconds(2); From f39a677896a7650590d4557645b9863354c328ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:34:21 +0900 Subject: [PATCH 128/178] test(lifecycle): close deletion replay coverage gaps --- ...actDeletionLedgerReplayValidationTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java index af23162ac..a310f9156 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java @@ -306,6 +306,50 @@ void retriedPendingStateMustPreserveAttemptCount() throws IOException { assertInvalidLedger(ledgerPath); } + @Test + void firstPendingStateMustNotInventAttemptEvidence() throws IOException { + Path ledgerPath = ledgerPath(); + Instant tombstonedAt = REQUESTED_AT.plusSeconds(1); + Instant pendingAt = REQUESTED_AT.plusSeconds(2); + 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, + 1, tombstonedAt, null, null), + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + @Test + void completedReceiptCannotTransitionAgainDuringReplay() throws IOException { + Path ledgerPath = ledgerPath(); + Instant tombstonedAt = REQUESTED_AT.plusSeconds(1); + Instant pendingAt = REQUESTED_AT.plusSeconds(2); + Instant completedAt = REQUESTED_AT.plusSeconds(3); + Instant duplicateCompletedAt = 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_COMPLETED, completedAt, + 0, null, completedAt, null) + + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, duplicateCompletedAt, + 0, null, duplicateCompletedAt, null), + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + private Path ledgerPath() { return tempDirectory.resolve("artifact_deletion_receipt.log"); } From 2e8e5ad46da7bb648e9e3f8a394c29d158345c5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 22:39:31 +0900 Subject: [PATCH 129/178] docs(repository): document process-local constructor --- .../repository/InMemoryConversionJobRepository.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 8b074888d..40f113655 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -43,6 +43,18 @@ public class InMemoryConversionJobRepository implements ConversionJobRepository, private final ConcurrentLinkedQueue lifecycleEvents = new ConcurrentLinkedQueue<>(); private final Object jobIndexLock = new Object(); + /** + * Creates an empty process-local repository. + * + *

All primary records, tenant-scoped indexes, permanently reserved job + * identifiers, and append-only lifecycle events start empty and are owned + * exclusively by this repository instance. The repository does not persist + * data across process restarts.

+ */ + public InMemoryConversionJobRepository() { + // Field initializers establish the complete empty repository state. + } + /** * {@inheritDoc} */ From 97384367f3b420e05b7340399d5a4ae73e3e2834 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 23:09:58 +0900 Subject: [PATCH 130/178] fix(ci): preserve exact-head checks for stacked pull requests --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52499d2b5..6bbdcb0a2 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 From 4936743e2a0f3d6cdb9bd1e831b304be00e3eca7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 23:10:08 +0900 Subject: [PATCH 131/178] test(ci): lock stacked pull-request coverage contract --- scripts/test_ci_workflow_stack_coverage.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/test_ci_workflow_stack_coverage.py diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py new file mode 100644 index 000000000..8a3af8b7e --- /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 From ed3863209109cd24a3cf1f1d6bdcd51db16fbef1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 01:20:15 +0900 Subject: [PATCH 132/178] test(ci): support standard script discovery --- scripts/test_ci_workflow_stack_coverage.py | 35 ++++++++++++++-------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py index 8a3af8b7e..2183cb5a6 100644 --- a/scripts/test_ci_workflow_stack_coverage.py +++ b/scripts/test_ci_workflow_stack_coverage.py @@ -1,24 +1,33 @@ +"""Verify that stacked pull requests retain the complete CI acceptance contract.""" + from pathlib import Path +import unittest 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") +class CiWorkflowStackCoverageTest(unittest.TestCase): + """Exercises the stacked-pull-request workflow contract with standard discovery.""" + + def test_ci_runs_for_every_pull_request_base(self) -> None: + """Stacked pull requests must receive the same exact-head CI as main-bound PRs.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn(" pull_request: {}", workflow) + self.assertNotIn(" pull_request:\n branches: [main]", workflow) - assert " pull_request: {}" in workflow - assert " pull_request:\n branches: [main]" not in workflow + def test_ci_preserves_exact_head_and_synthetic_merge_evidence(self) -> 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" + self.assertGreaterEqual(workflow.count(exact_head_expression), 4) + self.assertIn('test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"', workflow) + self.assertIn("name: Maven merge compatibility", workflow) + self.assertIn("name: Buyer-readiness script tests", 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 +if __name__ == "__main__": + unittest.main() From 633faecef76375f457e4e226b5447a86191ba83c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:32:30 +0900 Subject: [PATCH 133/178] test(lifecycle): require durable recovery after initial artifact read failure --- ...tifactDeletionInitialReadRecoveryTest.java | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialReadRecoveryTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialReadRecoveryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialReadRecoveryTest.java new file mode 100644 index 000000000..b6a025921 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialReadRecoveryTest.java @@ -0,0 +1,114 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Regression coverage for receipt-first recovery when the very first artifact + * read fails before an exact cleanup digest can be captured. + */ +class ArtifactDeletionInitialReadRecoveryTest { + + private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + private static final String TENANT_ID = "tenant-north"; + private static final byte[] ARTIFACT = "%PDF-1.7\ninitial-read-recovery".getBytes(StandardCharsets.UTF_8); + + @TempDir + Path tempDirectory; + + @Test + void initialArtifactReadFailurePersistsRetryIntentAndRecoversAfterRestart() { + Path ledgerPath = tempDirectory.resolve("artifact-deletion-receipts.log"); + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + repository.save(new ConversionJob( + JOB_ID, + TENANT_ID, + "subject-north", + "board-pack.pdf", + "application/pdf", + "initial-read-recovery-hash", + ARTIFACT.length, + 3 + )); + FailingFirstReadArtifactStore artifactStore = new FailingFirstReadArtifactStore(); + artifactStore.putPdf(JOB_ID, ARTIFACT); + ArtifactDeletionLedger firstLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionMetrics firstMetrics = new ArtifactDeletionMetrics(firstLedger); + ArtifactDeletionCoordinator firstProcess = new ArtifactDeletionCoordinator( + repository, + artifactStore, + firstLedger, + firstMetrics, + 100 + ); + + assertTrue(firstProcess.deleteForTenant(JOB_ID, TENANT_ID)); + + ArtifactDeletionReceipt retained = firstLedger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, retained.state()); + assertEquals(ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, retained.artifactChecksum()); + assertTrue(repository.findByTenantAndId(TENANT_ID, JOB_ID).isPresent()); + assertEquals(1L, firstMetrics.failedAttempts()); + assertEquals(1, firstMetrics.pendingReceipts()); + + ArtifactDeletionLedger restartedLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionMetrics restartedMetrics = new ArtifactDeletionMetrics(restartedLedger); + ArtifactDeletionCoordinator restartedProcess = new ArtifactDeletionCoordinator( + repository, + artifactStore, + restartedLedger, + restartedMetrics, + 100 + ); + + restartedProcess.recoverPendingAfterStartup(); + + ArtifactDeletionReceipt completed = restartedLedger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, completed.state()); + assertEquals(0, restartedMetrics.pendingReceipts()); + assertEquals(1L, restartedMetrics.completedAttempts()); + assertTrue(repository.findByTenantAndId(TENANT_ID, JOB_ID).isEmpty()); + assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); + } + + /** + * Fails exactly the first read while retaining the original artifact bytes + * for a later recovery pass. + */ + private static final class FailingFirstReadArtifactStore implements ArtifactStore { + private final InMemoryArtifactStore delegate = new InMemoryArtifactStore(); + private final AtomicBoolean failNextRead = new AtomicBoolean(true); + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + delegate.putPdf(docId, pdfBytes); + } + + @Override + public Optional getPdf(UUID docId) { + if (failNextRead.compareAndSet(true, false)) { + throw new IllegalStateException("artifact read failed"); + } + return delegate.getPdf(docId); + } + + @Override + public void deletePdf(UUID docId) { + delegate.deletePdf(docId); + } + } +} From 3752ba7cec65b65b16445edf3b8aecd855329d45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:33:24 +0900 Subject: [PATCH 134/178] fix(lifecycle): model durable pre-snapshot deletion receipts --- .../lifecycle/ArtifactDeletionReceipt.java | 77 ++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java index 6e3893e9b..d403d3150 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java @@ -8,10 +8,17 @@ /** * Immutable evidence for one tenant-bound artifact-deletion lifecycle. * + *

A newly accepted deletion request may temporarily carry the controlled + * {@link #PENDING_ARTIFACT_CHECKSUM} marker until the artifact store can be + * read. Metadata must remain intact while that marker is present. Once an + * exact SHA-256 digest (or the explicit absent-artifact digest) is captured, + * the digest becomes immutable for the remainder of the 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 artifactChecksum lowercase SHA-256 digest binding cleanup to one artifact generation, + * or the controlled pending marker before the first successful snapshot * @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 @@ -36,6 +43,12 @@ public record ArtifactDeletionReceipt( String failureCode ) { + /** + * Controlled non-digest marker meaning that an authorized request is durable + * but the artifact generation has not yet been snapshotted successfully. + */ + static final String PENDING_ARTIFACT_CHECKSUM = "pending"; + 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}"); @@ -47,11 +60,12 @@ public record ArtifactDeletionReceipt( requestId = Objects.requireNonNull(requestId, "requestId"); tenantId = requireText(tenantId, "tenantId"); jobId = Objects.requireNonNull(jobId, "jobId"); - artifactChecksum = requireChecksum(artifactChecksum); + artifactChecksum = requireText(artifactChecksum, "artifactChecksum"); auditCorrelationId = requireText(auditCorrelationId, "auditCorrelationId"); requestedAt = Objects.requireNonNull(requestedAt, "requestedAt"); stateChangedAt = Objects.requireNonNull(stateChangedAt, "stateChangedAt"); state = Objects.requireNonNull(state, "state"); + validateChecksumForState(artifactChecksum, state); if (stateChangedAt.isBefore(requestedAt)) { throw new IllegalArgumentException("stateChangedAt must not precede requestedAt"); } @@ -80,23 +94,60 @@ public boolean isCompleted() { } /** - * Returns whether this receipt represents the same immutable request. + * Returns whether this receipt represents the same immutable lifecycle snapshot. + * + *

The artifact checksum is included because, after capture, changing it + * would rebind cleanup to different bytes. The ledger handles the one legal + * pending-marker-to-digest transition explicitly.

* * @param other candidate receipt identity * @return true when all immutable identity fields match */ public boolean hasSameIdentity(ArtifactDeletionReceipt other) { + return hasSameRequestIdentity(other) + && artifactChecksum.equals(other.artifactChecksum); + } + + boolean hasSameRequestIdentity(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); } + boolean isArtifactChecksumPending() { + return PENDING_ARTIFACT_CHECKSUM.equals(artifactChecksum); + } + + ArtifactDeletionReceipt captureArtifactChecksum(String checksum, Instant capturedAt) { + requireState(ArtifactDeletionState.DELETION_REQUESTED); + if (!isArtifactChecksumPending()) { + throw invalidTransition(); + } + String requiredChecksum = requireDigest(checksum); + return new ArtifactDeletionReceipt( + requestId, + tenantId, + jobId, + requiredChecksum, + auditCorrelationId, + requestedAt, + requireForwardTime(capturedAt), + ArtifactDeletionState.DELETION_REQUESTED, + attemptCount, + lastAttemptAt, + null, + null + ); + } + ArtifactDeletionReceipt markMetadataTombstoned(Instant transitionedAt) { requireState(ArtifactDeletionState.DELETION_REQUESTED); + if (isArtifactChecksumPending()) { + throw invalidTransition(); + } return snapshot( ArtifactDeletionState.METADATA_TOMBSTONED, requireForwardTime(transitionedAt), @@ -205,10 +256,8 @@ private static void validateStateFields( 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.DELETION_REQUESTED && hasAttempts) { + throw new IllegalArgumentException("requested receipt fields are inconsistent"); } if (currentState == ArtifactDeletionState.METADATA_TOMBSTONED && hasAttempts) { throw new IllegalArgumentException("tombstoned receipt fields are inconsistent"); @@ -235,7 +284,17 @@ private static void validateStateFields( } } - private static String requireChecksum(String value) { + private static void validateChecksumForState(String value, ArtifactDeletionState currentState) { + if (PENDING_ARTIFACT_CHECKSUM.equals(value)) { + if (currentState != ArtifactDeletionState.DELETION_REQUESTED) { + throw new IllegalArgumentException("pending artifact checksum is only valid before metadata tombstoning"); + } + return; + } + requireDigest(value); + } + + private static String requireDigest(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"); From 4997377e777880d7b730d05f970b9b62d06b65d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:33:41 +0900 Subject: [PATCH 135/178] fix(lifecycle): persist exact artifact snapshot before tombstone --- .../ArtifactDeletionReceiptStore.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java index bbf6fd4e7..1347d7101 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java @@ -18,10 +18,15 @@ public interface ArtifactDeletionReceiptStore { /** * Durably accepts one idempotent artifact-deletion request. * + *

The checksum may be the controlled pending marker while the first + * artifact snapshot has not yet succeeded. Metadata must not be tombstoned + * until {@link #recordArtifactChecksum(UUID, String, Instant)} has replaced + * that marker with an exact SHA-256 digest.

+ * * @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 artifactChecksum lowercase SHA-256 artifact digest or controlled pending marker * @param auditCorrelationId privacy-safe audit correlation identifier * @param requestedAt instant when the request became durable * @return new or previously accepted identical receipt @@ -35,6 +40,22 @@ ArtifactDeletionReceipt request( Instant requestedAt ); + /** + * Durably binds a previously accepted pre-snapshot request to the exact + * artifact generation observed under the lifecycle lock. + * + * @param jobId permanently reserved conversion-job identifier + * @param artifactChecksum lowercase SHA-256 artifact digest, including the + * explicit absent-artifact digest when no bytes exist + * @param capturedAt instant when the exact digest became durable + * @return updated receipt with an immutable exact artifact digest + */ + ArtifactDeletionReceipt recordArtifactChecksum( + UUID jobId, + String artifactChecksum, + Instant capturedAt + ); + /** * Records that tenant-owned job metadata has been tombstoned. * From 4614e191a4d830d2310fef2ee48cf22e74639359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:34:13 +0900 Subject: [PATCH 136/178] test(security): require durable initial artifact-read recovery --- ...ctDeletionInitialSnapshotRecoveryTest.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java new file mode 100644 index 000000000..5bf4cef34 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java @@ -0,0 +1,119 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Verifies that the first artifact read is protected by durable recovery evidence. + * + *

The regression deliberately fails the initial checksum snapshot. The + * authorized deletion request must remain recoverable across a process restart, + * metadata must remain intact until an exact artifact digest can be bound, and + * the later recovery pass must complete the same tenant-owned lifecycle.

+ */ +class ArtifactDeletionInitialSnapshotRecoveryTest { + + private static final String TENANT_ID = "tenant-north"; + private static final UUID JOB_ID = UUID.fromString("35c19f28-4ad9-4d06-8c2f-17b7053101ee"); + private static final byte[] ARTIFACT = "%PDF-1.7\ninitial-read-recovery" + .getBytes(StandardCharsets.UTF_8); + + @TempDir + Path tempDirectory; + + @Test + void initialReadFailureIsDurableAndRecoversAfterRestart() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ConversionJob job = mock(ConversionJob.class); + when(repository.findByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(Optional.of(job)); + when(repository.deleteByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(true); + + FailsFirstReadArtifactStore artifactStore = new FailsFirstReadArtifactStore(ARTIFACT); + Path ledgerPath = tempDirectory.resolve("artifact-deletion-initial-read.log"); + ArtifactDeletionLedger firstLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionMetrics firstMetrics = new ArtifactDeletionMetrics(firstLedger); + ArtifactDeletionCoordinator firstProcess = new ArtifactDeletionCoordinator( + repository, + artifactStore, + firstLedger, + firstMetrics, + 100 + ); + + assertTrue(firstProcess.deleteForTenant(JOB_ID, TENANT_ID)); + + ArtifactDeletionReceipt retained = firstLedger.findByJobId(JOB_ID).orElseThrow(); + assertEquals("artifact_store_read_failed", retained.failureCode()); + assertEquals(1, retained.attemptCount()); + assertEquals(1L, firstMetrics.failedAttempts()); + assertEquals(1, firstMetrics.pendingReceipts()); + verify(repository, never()).deleteByTenantAndId(TENANT_ID, JOB_ID); + + ArtifactDeletionLedger restartedLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionMetrics restartedMetrics = new ArtifactDeletionMetrics(restartedLedger); + ArtifactDeletionCoordinator restartedProcess = new ArtifactDeletionCoordinator( + repository, + artifactStore, + restartedLedger, + restartedMetrics, + 100 + ); + + assertEquals(1, restartedProcess.retryPendingWork()); + + ArtifactDeletionReceipt completed = restartedLedger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, completed.state()); + assertTrue(completed.artifactChecksum() != null && completed.artifactChecksum().matches("[0-9a-f]{64}")); + assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); + assertEquals(1L, restartedMetrics.completedAttempts()); + assertEquals(0, restartedMetrics.pendingReceipts()); + verify(repository).deleteByTenantAndId(TENANT_ID, JOB_ID); + } + + /** Artifact store that fails exactly the first read and then behaves normally. */ + private static final class FailsFirstReadArtifactStore implements ArtifactStore { + + private byte[] bytes; + private boolean failNextRead = true; + + private FailsFirstReadArtifactStore(byte[] bytes) { + this.bytes = Arrays.copyOf(bytes, bytes.length); + } + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + bytes = Arrays.copyOf(pdfBytes, pdfBytes.length); + } + + @Override + public Optional getPdf(UUID docId) { + if (failNextRead) { + failNextRead = false; + throw new IllegalStateException("simulated initial artifact read failure"); + } + return bytes == null ? Optional.empty() : Optional.of(Arrays.copyOf(bytes, bytes.length)); + } + + @Override + public void deletePdf(UUID docId) { + bytes = null; + } + } +} From b24b6d71f9483adb1e85991019a8fb37300528bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:34:28 +0900 Subject: [PATCH 137/178] fix(lifecycle): durably record artifact snapshot capture --- .../lifecycle/ArtifactDeletionLedger.java | 94 +++++++++++-------- 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java index b747da002..b4b24b7d8 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java @@ -34,9 +34,9 @@ * *

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.

+ * uses bounded strict UTF-8 lines and validates immutable request identity, + * exact artifact-digest capture, timestamps, and monotonic transitions before + * exposing pending work. An empty path selects the standalone in-memory adapter.

*/ @Repository public class ArtifactDeletionLedger implements ArtifactDeletionReceiptStore { @@ -55,9 +55,7 @@ public class ArtifactDeletionLedger implements ArtifactDeletionReceiptStore { private final ConcurrentMap receiptsByJobId = new ConcurrentHashMap<>(); private final Path ledgerPath; - /** - * Creates a standalone in-memory deletion ledger. - */ + /** Creates a standalone in-memory deletion ledger. */ public ArtifactDeletionLedger() { this((Path) null); } @@ -80,9 +78,7 @@ public ArtifactDeletionLedger( load(); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public synchronized ArtifactDeletionReceipt request( UUID requestId, @@ -119,48 +115,56 @@ public synchronized ArtifactDeletionReceipt request( return requested; } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ + @Override + public synchronized ArtifactDeletionReceipt recordArtifactChecksum( + UUID jobId, + String artifactChecksum, + Instant capturedAt + ) { + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + ArtifactDeletionReceipt current = receiptsByJobId.get(requiredJobId); + if (current == null) { + throw missingReceipt(); + } + ArtifactDeletionReceipt updated = current.captureArtifactChecksum(artifactChecksum, capturedAt); + if (!current.hasSameRequestIdentity(updated)) { + throw conflictingReceipt(); + } + append(updated); + receiptsByJobId.put(requiredJobId, updated); + return updated; + } + + /** {@inheritDoc} */ @Override public ArtifactDeletionReceipt markMetadataTombstoned(UUID jobId, Instant transitionedAt) { return transition(jobId, current -> current.markMetadataTombstoned(transitionedAt)); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public ArtifactDeletionReceipt markCleanupPending(UUID jobId, Instant transitionedAt) { return transition(jobId, current -> current.markCleanupPending(transitionedAt)); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public ArtifactDeletionReceipt recordCleanupFailure( UUID jobId, String failureCode, Instant attemptedAt ) { - return transition( - jobId, - current -> current.recordCleanupFailure(failureCode, attemptedAt) - ); + return transition(jobId, current -> current.recordCleanupFailure(failureCode, attemptedAt)); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public ArtifactDeletionReceipt markCleanupCompleted(UUID jobId, Instant completedAt) { return transition(jobId, current -> current.markCleanupCompleted(completedAt)); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public Optional findByJobId(UUID jobId) { if (jobId == null) { @@ -169,9 +173,7 @@ public Optional findByJobId(UUID jobId) { return Optional.ofNullable(receiptsByJobId.get(jobId)); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public List pendingReceipts() { return receiptsByJobId.values().stream() @@ -180,9 +182,7 @@ public List pendingReceipts() { .toList(); } - /** - * {@inheritDoc} - */ + /** {@inheritDoc} */ @Override public int pendingCount() { return Math.toIntExact(receiptsByJobId.values().stream() @@ -264,10 +264,11 @@ private void replayLine(String line) { receiptsByJobId.put(replayed.jobId(), replayed); return; } - if (!current.hasSameIdentity(replayed)) { + boolean checksumCapture = isChecksumCaptureTransition(current, replayed); + if (!current.hasSameIdentity(replayed) && !checksumCapture) { throw invalidLine(); } - validateReplayTransition(current, replayed); + validateReplayTransition(current, replayed, checksumCapture); receiptsByJobId.put(replayed.jobId(), replayed); } @@ -341,16 +342,31 @@ private static String serialize(ArtifactDeletionReceipt receipt) { ); } - private static void validateReplayTransition( + private static boolean isChecksumCaptureTransition( ArtifactDeletionReceipt current, ArtifactDeletionReceipt replayed + ) { + return current.state() == ArtifactDeletionState.DELETION_REQUESTED + && replayed.state() == ArtifactDeletionState.DELETION_REQUESTED + && current.isArtifactChecksumPending() + && !replayed.isArtifactChecksumPending() + && current.hasSameRequestIdentity(replayed) + && hasSameAttemptEvidence(current, replayed); + } + + private static void validateReplayTransition( + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed, + boolean checksumCapture ) { 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 DELETION_REQUESTED -> checksumCapture || ( + replayed.state() == ArtifactDeletionState.METADATA_TOMBSTONED + && hasSameAttemptEvidence(current, replayed) + ); case METADATA_TOMBSTONED -> replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING && hasSameAttemptEvidence(current, replayed); case ARTIFACT_CLEANUP_PENDING -> ( From 5d0e6f1acd0058596e677d600318d47739021f86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:35:27 +0900 Subject: [PATCH 138/178] fix(lifecycle): persist deletion intent before initial artifact read --- .../ArtifactDeletionCoordinator.java | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java index a673fdd6a..bf4aae4e4 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java @@ -26,11 +26,16 @@ * Coordinates receipt-first metadata tombstoning and restart-safe artifact cleanup. * *

Each document lifecycle is serialized by a fixed-memory per-job lock shared - * with conversion. Different document identifiers may progress concurrently, - * so a slow artifact store cannot serialize every deletion and recovery task in - * the process. Replaceable durable adapters may use transactions, - * object-generation preconditions, compare-and-set state, or a single-consumer - * outbox while preserving the same per-generation contract.

+ * with conversion. An authorized request is persisted before the first artifact + * read. If that read is temporarily unavailable, metadata remains intact and the + * pending receipt is recoverable after restart; metadata tombstoning begins only + * after the exact artifact digest (or explicit absence digest) is durable.

+ * + *

Different document identifiers may progress concurrently, so a slow + * artifact store cannot serialize every deletion and recovery task in the + * process. Replaceable durable adapters may use transactions, object-generation + * preconditions, compare-and-set state, or a single-consumer outbox while + * preserving the same per-generation contract.

*/ @Component public class ArtifactDeletionCoordinator { @@ -197,11 +202,8 @@ private boolean deleteForTenantLocked(UUID jobId, String tenantId) { if (existing.isEmpty()) { return resumeExistingReceiptForTenantLocked(jobId, tenantId); } - ArtifactDeletionReceipt receipt = requestReceipt( - tenantId, - jobId, - snapshotChecksum(jobId) - ); + ensureSha256Available(); + ArtifactDeletionReceipt receipt = requestReceipt(tenantId, jobId); resumeReceiptLocked(receipt); return true; } @@ -217,12 +219,9 @@ private void deleteGloballyLocked(UUID jobId) { repository.deleteById(jobId); return; } + ensureSha256Available(); ConversionJob job = existing.get(); - ArtifactDeletionReceipt receipt = requestReceipt( - job.getTenantId(), - jobId, - snapshotChecksum(jobId) - ); + ArtifactDeletionReceipt receipt = requestReceipt(job.getTenantId(), jobId); resumeReceiptLocked(receipt); } @@ -250,17 +249,48 @@ private void resumeReceiptLocked(ArtifactDeletionReceipt candidate) { } private void resumeRequested(ArtifactDeletionReceipt receipt) { - boolean deleted = repository.deleteByTenantAndId(receipt.tenantId(), receipt.jobId()); - if (!deleted && repository.findByTenantAndId(receipt.tenantId(), receipt.jobId()).isPresent()) { + ArtifactDeletionReceipt exactReceipt = receipt; + if (receipt.isArtifactChecksumPending()) { + Optional capturedChecksum = trySnapshotChecksum(receipt.jobId()); + if (capturedChecksum.isEmpty()) { + return; + } + exactReceipt = receiptStore.recordArtifactChecksum( + receipt.jobId(), + capturedChecksum.orElseThrow(), + Instant.now() + ); + } + boolean deleted = repository.deleteByTenantAndId(exactReceipt.tenantId(), exactReceipt.jobId()); + if (!deleted && repository.findByTenantAndId(exactReceipt.tenantId(), exactReceipt.jobId()).isPresent()) { throw new IllegalStateException("tenant-scoped metadata tombstone was not applied"); } ArtifactDeletionReceipt tombstoned = receiptStore.markMetadataTombstoned( - receipt.jobId(), + exactReceipt.jobId(), Instant.now() ); queueAndAttempt(tombstoned); } + private Optional trySnapshotChecksum(UUID jobId) { + Optional artifact; + try { + artifact = Objects.requireNonNull( + artifactStore.getPdf(jobId), + "artifactStore.getPdf" + ); + } catch (RuntimeException exception) { + metrics.recordFailed(); + log.warn( + "Artifact deletion retained a pre-snapshot receipt. cause={}", + exception.getClass().getName() + ); + return Optional.empty(); + } + return Optional.of(artifact.map(ArtifactDeletionCoordinator::sha256) + .orElse(ABSENT_ARTIFACT_CHECKSUM)); + } + private void queueAndAttempt(ArtifactDeletionReceipt receipt) { ArtifactDeletionReceipt pending = receiptStore.markCleanupPending( receipt.jobId(), @@ -301,16 +331,11 @@ private void recordFailure(ArtifactDeletionReceipt receipt, String failureCode) metrics.recordFailed(); } - private ArtifactDeletionReceipt requestReceipt( - String tenantId, - UUID jobId, - String artifactChecksum - ) { + private ArtifactDeletionReceipt requestReceipt(String tenantId, UUID jobId) { Optional existing = receiptStore.findByJobId(jobId); if (existing.isPresent()) { ArtifactDeletionReceipt receipt = existing.get(); - if (!receipt.tenantId().equals(tenantId) - || !receipt.artifactChecksum().equals(artifactChecksum)) { + if (!receipt.tenantId().equals(tenantId)) { throw new IllegalStateException("artifact deletion receipt conflicts with the active lifecycle"); } return receipt; @@ -320,19 +345,18 @@ private ArtifactDeletionReceipt requestReceipt( requestId, tenantId, jobId, - artifactChecksum, + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, "cleanup-v1:" + requestId.toString().replace("-", ""), Instant.now() ); } - private String snapshotChecksum(UUID jobId) { - Optional artifact = Objects.requireNonNull( - artifactStore.getPdf(jobId), - "artifactStore.getPdf" - ); - return artifact.map(ArtifactDeletionCoordinator::sha256) - .orElse(ABSENT_ARTIFACT_CHECKSUM); + private static void ensureSha256Available() { + try { + MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 digest unavailable", exception); + } } private static String sha256(byte[] bytes) { From 4b32c21a433b723028cad6d9ebae05399eea0a81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:35:57 +0900 Subject: [PATCH 139/178] fix(security): persist artifact snapshot lifecycle states --- .../viewer/lifecycle/ArtifactDeletionState.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java index de7287ad1..cf8af7746 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java @@ -5,7 +5,20 @@ */ public enum ArtifactDeletionState { /** - * An authorized deletion request has been durably accepted. + * An authorized deletion request is durable, but the artifact generation + * has not yet been read and bound to an exact checksum. + */ + ARTIFACT_SNAPSHOT_PENDING, + + /** + * The artifact generation could not be read and remains eligible for a + * controlled retry while tenant-owned metadata is left intact. + */ + ARTIFACT_SNAPSHOT_FAILED, + + /** + * An authorized deletion request has an exact artifact checksum and is + * ready for tenant-scoped metadata tombstoning. */ DELETION_REQUESTED, From fe34d3dc205b639f8a5c200c7e550bb6681bcd8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:37:39 +0900 Subject: [PATCH 140/178] fix(build): align lifecycle enum with durable pre-snapshot marker state --- .../viewer/lifecycle/ArtifactDeletionState.java | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java index cf8af7746..0f45c46f6 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java @@ -5,20 +5,9 @@ */ public enum ArtifactDeletionState { /** - * An authorized deletion request is durable, but the artifact generation - * has not yet been read and bound to an exact checksum. - */ - ARTIFACT_SNAPSHOT_PENDING, - - /** - * The artifact generation could not be read and remains eligible for a - * controlled retry while tenant-owned metadata is left intact. - */ - ARTIFACT_SNAPSHOT_FAILED, - - /** - * An authorized deletion request has an exact artifact checksum and is - * ready for tenant-scoped metadata tombstoning. + * An authorized deletion request has been durably accepted. The receipt may + * still carry the controlled pre-snapshot marker until an exact artifact + * checksum is captured; metadata cannot be tombstoned before that binding. */ DELETION_REQUESTED, From cd93d9feebffa0c8dc4908e8dd6d1745705a626a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:38:44 +0900 Subject: [PATCH 141/178] test(security): remove duplicate initial-read recovery regression --- ...ctDeletionInitialSnapshotRecoveryTest.java | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java deleted file mode 100644 index 5bf4cef34..000000000 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionInitialSnapshotRecoveryTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.clearfolio.viewer.lifecycle; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import com.clearfolio.viewer.artifact.ArtifactStore; -import com.clearfolio.viewer.model.ConversionJob; -import com.clearfolio.viewer.repository.ConversionJobRepository; - -/** - * Verifies that the first artifact read is protected by durable recovery evidence. - * - *

The regression deliberately fails the initial checksum snapshot. The - * authorized deletion request must remain recoverable across a process restart, - * metadata must remain intact until an exact artifact digest can be bound, and - * the later recovery pass must complete the same tenant-owned lifecycle.

- */ -class ArtifactDeletionInitialSnapshotRecoveryTest { - - private static final String TENANT_ID = "tenant-north"; - private static final UUID JOB_ID = UUID.fromString("35c19f28-4ad9-4d06-8c2f-17b7053101ee"); - private static final byte[] ARTIFACT = "%PDF-1.7\ninitial-read-recovery" - .getBytes(StandardCharsets.UTF_8); - - @TempDir - Path tempDirectory; - - @Test - void initialReadFailureIsDurableAndRecoversAfterRestart() { - ConversionJobRepository repository = mock(ConversionJobRepository.class); - ConversionJob job = mock(ConversionJob.class); - when(repository.findByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(Optional.of(job)); - when(repository.deleteByTenantAndId(TENANT_ID, JOB_ID)).thenReturn(true); - - FailsFirstReadArtifactStore artifactStore = new FailsFirstReadArtifactStore(ARTIFACT); - Path ledgerPath = tempDirectory.resolve("artifact-deletion-initial-read.log"); - ArtifactDeletionLedger firstLedger = new ArtifactDeletionLedger(ledgerPath); - ArtifactDeletionMetrics firstMetrics = new ArtifactDeletionMetrics(firstLedger); - ArtifactDeletionCoordinator firstProcess = new ArtifactDeletionCoordinator( - repository, - artifactStore, - firstLedger, - firstMetrics, - 100 - ); - - assertTrue(firstProcess.deleteForTenant(JOB_ID, TENANT_ID)); - - ArtifactDeletionReceipt retained = firstLedger.findByJobId(JOB_ID).orElseThrow(); - assertEquals("artifact_store_read_failed", retained.failureCode()); - assertEquals(1, retained.attemptCount()); - assertEquals(1L, firstMetrics.failedAttempts()); - assertEquals(1, firstMetrics.pendingReceipts()); - verify(repository, never()).deleteByTenantAndId(TENANT_ID, JOB_ID); - - ArtifactDeletionLedger restartedLedger = new ArtifactDeletionLedger(ledgerPath); - ArtifactDeletionMetrics restartedMetrics = new ArtifactDeletionMetrics(restartedLedger); - ArtifactDeletionCoordinator restartedProcess = new ArtifactDeletionCoordinator( - repository, - artifactStore, - restartedLedger, - restartedMetrics, - 100 - ); - - assertEquals(1, restartedProcess.retryPendingWork()); - - ArtifactDeletionReceipt completed = restartedLedger.findByJobId(JOB_ID).orElseThrow(); - assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, completed.state()); - assertTrue(completed.artifactChecksum() != null && completed.artifactChecksum().matches("[0-9a-f]{64}")); - assertTrue(artifactStore.getPdf(JOB_ID).isEmpty()); - assertEquals(1L, restartedMetrics.completedAttempts()); - assertEquals(0, restartedMetrics.pendingReceipts()); - verify(repository).deleteByTenantAndId(TENANT_ID, JOB_ID); - } - - /** Artifact store that fails exactly the first read and then behaves normally. */ - private static final class FailsFirstReadArtifactStore implements ArtifactStore { - - private byte[] bytes; - private boolean failNextRead = true; - - private FailsFirstReadArtifactStore(byte[] bytes) { - this.bytes = Arrays.copyOf(bytes, bytes.length); - } - - @Override - public void putPdf(UUID docId, byte[] pdfBytes) { - bytes = Arrays.copyOf(pdfBytes, pdfBytes.length); - } - - @Override - public Optional getPdf(UUID docId) { - if (failNextRead) { - failNextRead = false; - throw new IllegalStateException("simulated initial artifact read failure"); - } - return bytes == null ? Optional.empty() : Optional.of(Arrays.copyOf(bytes, bytes.length)); - } - - @Override - public void deletePdf(UUID docId) { - bytes = null; - } - } -} From 7689d64633bd95f8a68aa1fd0dae4c9084b60394 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:39:32 +0900 Subject: [PATCH 142/178] test(security): expect durable receipt before initial artifact read --- .../ArtifactDeletionCoordinatorTest.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java index c7d55c4b2..67ec67bb1 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorTest.java @@ -94,26 +94,27 @@ void crossTenantAndMissingContextBoundariesDoNotTouchArtifactOrReceiptStores() { } @Test - void artifactSnapshotFailurePrecedesReceiptAndMetadataMutation() { + void artifactSnapshotFailureRetainsDurableReceiptBeforeMetadataMutation() { InMemoryConversionJobRepository repository = repositoryWithJob(); ArtifactStore artifactStore = throwingStore(true, false); ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(ledger); ArtifactDeletionCoordinator coordinator = coordinator( repository, artifactStore, ledger, - new ArtifactDeletionMetrics(ledger), + metrics, 100 ); - IllegalStateException exception = assertThrows( - IllegalStateException.class, - () -> coordinator.deleteForTenant(JOB_ID, TENANT_ID) - ); + assertTrue(coordinator.deleteForTenant(JOB_ID, TENANT_ID)); - assertEquals("artifact read failed", exception.getMessage()); + ArtifactDeletionReceipt retained = ledger.findByJobId(JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, retained.state()); + assertEquals(ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, retained.artifactChecksum()); assertTrue(repository.findById(JOB_ID).isPresent()); - assertTrue(ledger.findByJobId(JOB_ID).isEmpty()); + assertEquals(1L, metrics.failedAttempts()); + assertEquals(1, metrics.pendingReceipts()); } @Test From cee2002f31bac70830f1ca31687c8234b7d40bc4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:40:58 +0900 Subject: [PATCH 143/178] docs(security): document durable pre-snapshot deletion evidence --- ...8-06-durable-artifact-deletion-receipts.md | 132 +++++++++++------- 1 file changed, 82 insertions(+), 50 deletions(-) diff --git a/docs/security/2026-08-06-durable-artifact-deletion-receipts.md b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md index 3db4ea28b..ba6541eba 100644 --- a/docs/security/2026-08-06-durable-artifact-deletion-receipts.md +++ b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md @@ -8,11 +8,11 @@ ## Decision Clearfolio records every accepted artifact-deletion lifecycle as immutable, -versioned receipt snapshots before removing conversion-job metadata. The Spring -runtime uses an append-only file ledger at -`data/artifact-deletion-receipts.log`, while standalone and test consumers may -use the in-memory adapter behind the same `ArtifactDeletionReceiptStore` -contract. +versioned receipt snapshots **before the first artifact-store read and before +removing conversion-job metadata**. The Spring runtime uses an append-only file +ledger at `data/artifact-deletion-receipts.log`, while standalone and test +consumers may use the in-memory adapter behind the same +`ArtifactDeletionReceiptStore` contract. `DurableDocumentDeletionService` is the primary `DocumentConversionService` decorator. It delegates submission, lookup, retry, and listing to the existing @@ -20,10 +20,15 @@ decorator. It delegates submission, lookup, retry, and listing to the existing deletion to `ArtifactDeletionCoordinator`. This preserves conversion behavior and keeps the deletion worker independently replaceable. -The coordinator persists deletion intent, tombstones metadata, attempts exact -artifact cleanup, records controlled failure evidence, and recovers incomplete -work after startup and on a bounded fixed-delay schedule. A storage exception is -no longer reduced to a log line and lost. +The coordinator first persists deletion intent with a controlled +`pending` artifact-checksum marker. It then reads the artifact under the same +per-job lifecycle fence. A successful read durably replaces the marker with the +exact SHA-256 digest, or with the explicit absent-artifact digest when no bytes +exist. Only after that exact binding may metadata be tombstoned and physical +cleanup begin. If the initial artifact read fails, metadata remains intact, the +pending receipt remains restart-recoverable, and a low-cardinality failed +attempt signal is emitted. An unreadable artifact is never represented by the +absent-artifact digest. The reference implementation closes the process-local and restart-loss gap that caused CWE-459 orphaned document bytes. A future database deployment must still @@ -44,10 +49,17 @@ 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 observed artifact bytes; +- `artifact_checksum`: the controlled `pending` marker until the first + successful artifact snapshot, then the immutable lowercase SHA-256 digest for + the observed generation; - `audit_correlation_id`: random privacy-safe lifecycle correlation; - `requested_at`: durable request time. +The `pending` marker is deliberately **not** a digest and is valid only while the +receipt remains `DELETION_REQUESTED`. `markMetadataTombstoned` rejects a pending +marker. The marker therefore cannot be interpreted as proof that bytes are +absent and cannot weaken the exact-generation deletion fence. + Raw subjects, filenames, tokens, storage paths, exception messages, and document content never enter the receipt or aggregate operational evidence. Repeated same-tenant DELETE requests resume or observe the existing receipt and preserve @@ -57,8 +69,9 @@ the same intended effect. Conflicting tenant or artifact identity fails closed. ```mermaid stateDiagram-v2 - [*] --> DELETION_REQUESTED - DELETION_REQUESTED --> METADATA_TOMBSTONED + [*] --> DELETION_REQUESTED: persist pending marker + DELETION_REQUESTED --> DELETION_REQUESTED: bind exact digest + DELETION_REQUESTED --> METADATA_TOMBSTONED: digest must be bound METADATA_TOMBSTONED --> ARTIFACT_CLEANUP_PENDING ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_COMPLETED ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_FAILED @@ -67,51 +80,63 @@ stateDiagram-v2 ``` Every snapshot records `state_changed_at`; time cannot move backward and -`ARTIFACT_CLEANUP_COMPLETED` is terminal. Failures increment `attempt_count`, -record `last_attempt_at`, and accept only controlled lowercase codes matching -`[a-z0-9_]{1,64}`. Current coordinator codes are: +`ARTIFACT_CLEANUP_COMPLETED` is terminal. Cleanup failures increment +`attempt_count`, record `last_attempt_at`, and accept only controlled lowercase +codes matching `[a-z0-9_]{1,64}`. The pre-snapshot read failure remains a +`DELETION_REQUESTED` receipt with the pending marker and is additionally counted +by the dimension-free failed-attempt metric; it does not fabricate a cleanup +failure transition before an exact generation exists. Current cleanup codes are: - `artifact_store_read_failed`; - `artifact_store_delete_failed`; - `artifact_checksum_mismatch`. The ledger rejects inconsistent attempt evidence, illegal successors, -non-monotonic timestamps, unsafe failure details, malformed identity, and -attempt erasure across retry or completion. +non-monotonic timestamps, unsafe failure details, malformed identity, illegal +checksum rebinding, and attempt erasure across retry or completion. ## Receipt-first deletion flow 1. Resolve tenant ownership at the repository boundary. -2. Read the current artifact before metadata mutation. -3. Bind a present artifact to SHA-256. Bind absence to the SHA-256 digest of the - empty byte sequence. -4. Force the `DELETION_REQUESTED` receipt to durable storage. -5. Tombstone metadata without releasing the UUID reservation. -6. Force `METADATA_TOMBSTONED` and `ARTIFACT_CLEANUP_PENDING`. -7. Re-read the artifact before deletion. A non-empty expected digest must match. -8. Delete bytes and sidecar metadata through `ArtifactStore.deletePdf`. -9. Force `ARTIFACT_CLEANUP_COMPLETED`, or force one controlled failed state for - later retry. - -The empty digest is an explicit absence sentinel. A non-empty mismatched digest -is retained and flagged instead of deleting bytes that cannot be proven to match -the receipt. +2. Confirm that SHA-256 is available before accepting work that cannot later be + generation-bound. +3. Force a `DELETION_REQUESTED` receipt with the controlled `pending` checksum + marker to durable storage. +4. Read the current artifact under the per-job lifecycle fence. If the read + fails or returns an invalid result, retain the pending receipt, keep metadata + intact, emit aggregate failure evidence, and retry after startup or schedule. +5. On a successful read, bind a present artifact to SHA-256. Bind true absence + to the SHA-256 digest of the empty byte sequence. Force the same receipt + identity with that exact digest before any metadata mutation. +6. Tombstone metadata without releasing the UUID reservation. +7. Force `METADATA_TOMBSTONED` and `ARTIFACT_CLEANUP_PENDING`. +8. Re-read the artifact before deletion. A non-empty expected digest must match. +9. Delete bytes and sidecar metadata through `ArtifactStore.deletePdf`. +10. Force `ARTIFACT_CLEANUP_COMPLETED`, or force one controlled cleanup-failed + state for later retry. + +The empty digest is an explicit **confirmed-absence** sentinel. It is written +only after `ArtifactStore.getPdf` has successfully returned an empty +`Optional`. An exception or invalid read result keeps the non-digest `pending` +marker instead. A non-empty mismatched digest is retained and flagged instead +of deleting bytes that cannot be proven to match the receipt. ## Conversion/deletion generation fence `ArtifactLifecycleLockRegistry` provides a fixed-memory per-job lock stripe in the standalone process. `ArtifactDeletionCoordinator` and `LifecycleFencedArtifactStore` use the same Spring-managed registry. Artifact -reads, writes, and deletes are therefore serialized with receipt creation and -metadata tombstoning for the same job identifier. +reads, writes, and deletes are therefore serialized with receipt creation, +checksum binding, and metadata tombstoning for the same job identifier. `LifecycleFencedArtifactStore.putPdf` rejects every write after a durable receipt -exists, including pending and failed cleanup. This prevents an already-running -conversion from recreating bytes after cleanup completes. If publication wins -the lock first, deletion snapshots and removes that published generation. If -deletion wins first, the later write fails closed. Multi-instance and remote -object-store adapters must provide an equivalent distributed generation fence or -object-version precondition. +exists, including a pre-snapshot pending receipt and failed cleanup. This +prevents an already-running conversion from recreating bytes after cleanup +completes or changing the generation between a failed initial read and its later +recovery. If publication wins the lock first, deletion snapshots and removes +that published generation. If deletion wins first, the later write fails closed. +Multi-instance and remote object-store adapters must provide an equivalent +distributed generation fence or object-version precondition. ## Persistence and crash recovery @@ -121,14 +146,18 @@ LF commit delimiter, and is appended through `FileChannel`. The ledger calls `force(true)` before exposing the transition as durable. Startup replay rejects malformed Base64URL, invalid state, timestamp or count, -immutable-identity conflicts, oversized records, illegal transitions, and every -non-empty unterminated final tail. It never silently truncates forensic evidence. -A missing ledger means an empty store; a malformed existing ledger prevents +immutable-request conflicts, illegal pending-marker placement, unsupported +checksum rebinding, oversized records, illegal transitions, and every non-empty +unterminated final tail. It never silently truncates forensic evidence. A +missing ledger means an empty store; a malformed existing ledger prevents startup. `ArtifactDeletionCoordinator` replays every nonterminal state: -- `DELETION_REQUESTED`: confirm or apply the tenant metadata tombstone; +- `DELETION_REQUESTED` with `pending`: retry the exact artifact snapshot while + metadata remains intact; if successful, durably bind the digest and continue; +- `DELETION_REQUESTED` with an exact digest: confirm or apply the tenant metadata + tombstone; - `METADATA_TOMBSTONED`: queue cleanup; - `ARTIFACT_CLEANUP_PENDING`: retry the interrupted attempt; - `ARTIFACT_CLEANUP_FAILED`: return to pending and retry; @@ -164,7 +193,9 @@ service adapters must preserve: - tenant authorization before lookup or mutation; - one permanently reserved `job_id` lifecycle; +- a durable pre-snapshot deletion intent before the first artifact read; - exact immutable request identity and idempotency; +- one-way pending-marker-to-exact-digest binding before metadata tombstoning; - artifact digest or generation binding; - conversion/deletion generation fencing; - monotonic state and time; @@ -195,13 +226,14 @@ CodeRabbit, OpenCode/Noema, and independent protected-branch approval. ## Verification requirements The deterministic suite covers tenant concealment, successful completion, -read-before-mutation failure, durable delete failure, controlled failure codes, -restart replay, already-tombstoned recovery, pending-at-crash recovery, digest -mismatch, repeated DELETE idempotency, write-after-receipt rejection, -in-flight publication serialization, bounded batches, aggregate evidence, -invalid configuration, conflicting identities, every legal and illegal ledger -transition, strict UTF-8, oversized input, unterminated-tail rejection, and -completed-receipt retention. +durable pre-snapshot read failure across restart, metadata preservation before +checksum binding, confirmed absence versus unreadable-artifact distinction, +durable delete failure, controlled cleanup failure codes, restart replay, +already-tombstoned recovery, pending-at-crash recovery, digest mismatch, +repeated DELETE idempotency, write-after-receipt rejection, in-flight publication +serialization, bounded batches, aggregate evidence, invalid configuration, +conflicting identities, legal and illegal ledger transitions, strict UTF-8, +oversized input, unterminated-tail rejection, and completed-receipt retention. ## References From 707382b92042778a4404a1788d78d57cfaa6caae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:41:23 +0900 Subject: [PATCH 144/178] docs(operations): add pre-snapshot receipt recovery runbook --- docs/operations/artifact-deletion-cleanup.md | 60 ++++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/docs/operations/artifact-deletion-cleanup.md b/docs/operations/artifact-deletion-cleanup.md index 684e25c2a..66e0291bf 100644 --- a/docs/operations/artifact-deletion-cleanup.md +++ b/docs/operations/artifact-deletion-cleanup.md @@ -6,18 +6,26 @@ This runbook covers the durable cleanup worker introduced for issue #263. It is for operators of the standalone Spring service and for teams implementing a compatible database, queue, or object-store adapter. -The authenticated administrator DELETE request may return after metadata has -been tombstoned while physical byte deletion remains retryable. Until the API +The authenticated administrator DELETE request may return while the receipt is +still waiting for an exact artifact snapshot, after metadata has been +tombstoned, or while physical byte deletion remains retryable. Until the API status slice is integrated, the authoritative physical-cleanup state is the `ArtifactDeletionReceiptStore`, not the HTTP response alone. +A `DELETION_REQUESTED` receipt can contain the controlled non-digest checksum +marker `pending`. That marker means the request itself is durable but the first +artifact read has not succeeded yet. Metadata must still exist in this state. +The worker replaces the marker with an exact SHA-256 digest, or the explicit +confirmed-absence digest, before metadata tombstoning. An artifact-store read +exception never becomes the absence digest. + ## Safe operational signals `ArtifactDeletionMetrics` exposes only aggregate, dimension-free values: - `completedAttempts()` — cleanup attempts that reached a durable completed receipt; -- `failedAttempts()` — controlled failures retained for retry; +- `failedAttempts()` — pre-snapshot or cleanup failures retained for retry; - `pendingReceipts()` — all nonterminal durable receipts; - `recoveryBatchRuns()` — measured bounded recovery invocations; - `recoveryBatchTotalDuration()` — cumulative elapsed recovery time; @@ -40,11 +48,13 @@ retry intervals: 1. `pendingReceipts()` is greater than zero and does not decrease. 2. `failedAttempts()` increases while `completedAttempts()` does not. -3. `recoveryBatchMaximumDuration()` approaches the scheduler interval or the +3. A `DELETION_REQUESTED` receipt remains bound to the `pending` marker, which + indicates that the artifact generation still cannot be read safely. +4. `recoveryBatchMaximumDuration()` approaches the scheduler interval or the artifact-store client timeout. -4. The receipt ledger cannot be loaded, contains an unterminated final record, - or rejects an illegal transition. -5. Disk capacity for the ledger or artifact root approaches the deployment's +5. The receipt ledger cannot be loaded, contains an unterminated final record, + or rejects an illegal transition or checksum rebinding. +6. Disk capacity for the ledger or artifact root approaches the deployment's reserved minimum. A transient failed-attempt increment followed by completed growth and a falling @@ -56,17 +66,25 @@ pending count is expected recovery evidence, not data loss. evidence. Do not edit or truncate it. 2. Confirm the service exact build provenance, receipt format version, artifact store configuration, and scheduler settings. -3. Determine whether pending receipts are in `DELETION_REQUESTED`, - `METADATA_TOMBSTONED`, `ARTIFACT_CLEANUP_PENDING`, or - `ARTIFACT_CLEANUP_FAILED`. +3. Determine whether pending receipts are: + - `DELETION_REQUESTED` with checksum marker `pending` — metadata must remain + intact while the worker retries the artifact snapshot; + - `DELETION_REQUESTED` with an exact digest — metadata tombstoning may resume; + - `METADATA_TOMBSTONED`; + - `ARTIFACT_CLEANUP_PENDING`; or + - `ARTIFACT_CLEANUP_FAILED`. 4. Validate storage reachability, credentials, filesystem permissions, quota, object-version preconditions, and timeout behavior without printing a raw document path or identifier. -5. Correct the infrastructure cause and allow startup or scheduled replay to - resume the receipt. Do not fabricate a completed transition. -6. Confirm that pending count falls, completed count increases, and no new +5. For a pending pre-snapshot receipt, verify that no operator or adapter has + substituted the empty digest to force progress. The empty digest is valid + only after the store successfully reports absence. +6. Correct the infrastructure cause and allow startup or scheduled replay to + resume the receipt. Do not fabricate a checksum, tombstone, or completed + transition. +7. Confirm that pending count falls, completed count increases, and no new controlled failure is recorded. -7. Retain only privacy-safe aggregate evidence in a buyer-shareable report. +8. Retain only privacy-safe aggregate evidence in a buyer-shareable report. Keep the raw ledger, infrastructure logs, and document identifiers in the restricted operational boundary. @@ -77,9 +95,15 @@ ledger fails closed; replacing it with an empty file is not a valid recovery. Restore the exact last known durable ledger from protected operational backup or repair the storage fault that prevented reading it. +A restart may legitimately encounter `DELETION_REQUESTED` with `pending`. The +worker must retry the artifact read under the same lifecycle fence, durably bind +the exact digest, and only then tombstone metadata. This is the expected recovery +path for an outage during the very first artifact read. + Application rollback is safe only to a build that understands every receipt -format already present. A build that does not recognize `RECEIPT_V1`, the current -state machine, or the generation fence must not start against the ledger. Preserve +format already present, including the controlled pending-marker checksum +contract. A build that does not recognize `RECEIPT_V1`, the current state +machine, or the generation fence must not start against the ledger. Preserve artifacts and receipts until the compatible build has resumed all pending work. ## Multi-instance requirements @@ -95,7 +119,9 @@ one of the following at the adapter boundary: expired lock holder. A plain distributed mutex without a fencing token is not sufficient evidence -that a stale worker cannot publish bytes after deletion. +that a stale worker cannot publish bytes after deletion. The remote adapter must +also preserve the one-way transition from pending snapshot evidence to an exact +artifact generation before any metadata tombstone becomes visible. ## Acceptance evidence From a760648edb189b0143de4d93b512ccb2c518bb13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 06:42:10 +0900 Subject: [PATCH 145/178] docs(changelog): record durable initial artifact-read recovery --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c43484e..bf4d202b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,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 evidence, 시각 순서, immutable identity, legal successor를 검증합니다. Cleanup coordinator는 receipt를 먼저 영속화하고 metadata를 tombstone한 뒤 exact artifact digest를 재검증하며, read/delete/mismatch 실패를 controlled retry state로 보존합니다. +- 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 evidence, 시각 순서, immutable request identity, pending-marker-to-exact-digest의 단방향 binding, legal successor를 검증합니다. Cleanup coordinator는 첫 artifact read 전에 `DELETION_REQUESTED` receipt를 `pending` marker로 영속화하고, successful read 후 exact digest 또는 confirmed-absence digest를 force한 뒤에만 metadata tombstone을 허용합니다. Initial read failure는 absence로 오인하지 않고 metadata를 유지한 채 restart-safe retry evidence와 low-cardinality failure metric을 남깁니다. - Conversion과 deletion은 동일 per-job lifecycle lock을 공유합니다. Artifact boundary는 receipt가 생긴 job ID의 이후 `putPdf`를 거부하므로 deletion 완료 뒤 late worker가 confidential bytes를 재생성할 수 없습니다. Multi-instance adapter는 동일한 distributed generation fence 또는 object-version precondition을 구현해야 합니다. - Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다. - Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다. @@ -54,6 +54,7 @@ ### Fixed - 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. +- 관리자 삭제의 첫 artifact snapshot read가 실패하면 receipt조차 생성되지 않아 restart/scheduled recovery가 불가능하던 경로를 수정했습니다. 이제 read 전에 `pending` marker가 포함된 durable receipt를 force하고, exact digest가 성공적으로 bound될 때까지 metadata를 유지합니다. 회귀 테스트는 최초 read 실패 → process restart → exact digest binding → metadata tombstone → artifact cleanup 완료를 결정적으로 검증합니다. - 관리자 삭제에서 metadata 제거 뒤 artifact-store 실패가 로그로만 소실되던 CWE-459 경로를 durable receipt와 bounded recovery로 수정했습니다. 같은 tenant의 반복 DELETE는 기존 receipt를 resume/no-op하며, 다른 tenant receipt는 숨깁니다. - Legacy global DELETE가 metadata tombstone 뒤 실패한 receipt를 다시 호출했을 때 cleanup을 재개하지 않던 상태 전이 오류를 수정했습니다. @@ -88,4 +89,3 @@ - 과거 QA 증거 SBOM의 Jackson purl/ref도 `2.21.5`로 갱신해 Scorecard/OSV가 저장소 내 stale SBOM을 취약 의존성으로 재탐지하지 않게 했습니다. - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. - logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. -- 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. From da3269ca0fba68fe0d15c9a3fd2b009109fa378c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:31:22 +0900 Subject: [PATCH 146/178] test(docs): define durable cleanup documentation contract --- ..._durable_cleanup_documentation_contract.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 scripts/test_durable_cleanup_documentation_contract.py diff --git a/scripts/test_durable_cleanup_documentation_contract.py b/scripts/test_durable_cleanup_documentation_contract.py new file mode 100644 index 000000000..702738424 --- /dev/null +++ b/scripts/test_durable_cleanup_documentation_contract.py @@ -0,0 +1,70 @@ +"""Regression tests for truthful durable artifact-cleanup documentation. + +The integrated reference runtime persists deletion intent before metadata +removal, retries incomplete cleanup after restart and on a bounded schedule, +and exposes aggregate evidence. Public operating documents must describe that +implemented boundary while separately preserving the remaining distributed +transaction and remote object-store limitations. +""" + +from pathlib import Path +import unittest + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +AGENT_GUIDE = REPOSITORY_ROOT / "AGENTS.md" +AUTHORIZATION_DECISION = ( + REPOSITORY_ROOT + / "docs" + / "security" + / "2026-08-05-administrative-authorization.md" +) +DEPLOYMENT_PLAYBOOK = ( + REPOSITORY_ROOT + / "docs" + / "deployment" + / "2026-07-02-buyer-deployment-integration-playbook.md" +) + + +class DurableCleanupDocumentationContractTest(unittest.TestCase): + """Keeps buyer and operator claims aligned with the integrated runtime.""" + + def test_agent_guide_describes_integrated_worker_and_remaining_limits(self) -> None: + """The agent source of truth must not describe cleanup as absent.""" + content = AGENT_GUIDE.read_text(encoding="utf-8") + + self.assertIn("receipt-first cleanup worker", content) + self.assertIn("cross-resource transactional outbox", content) + self.assertNotIn( + "does not implement a failed-artifact\n cleanup queue, deletion receipt, outbox, or retry worker", + content, + ) + + def test_authorization_decision_distinguishes_local_durability_from_distributed_atomicity( + self, + ) -> None: + """The security decision must state both implemented and residual risk.""" + content = AUTHORIZATION_DECISION.read_text(encoding="utf-8") + + self.assertIn("durable deletion receipt", content) + self.assertIn("bounded scheduled recovery", content) + self.assertIn("distributed generation fence", content) + self.assertNotIn("This slice does not persist a deletion receipt", content) + self.assertNotIn("No durable artifact-cleanup evidence exists in this slice", content) + + def test_deployment_playbook_exposes_recovery_configuration_and_cutover_limits( + self, + ) -> None: + """Buyer operations must configure the worker without overstating it.""" + content = DEPLOYMENT_PLAYBOOK.read_text(encoding="utf-8") + + self.assertIn("artifact-deletion-receipts.log", content) + self.assertIn("bounded scheduled recovery", content) + self.assertIn("remote-object-store atomicity", content) + self.assertNotIn("does not configure `ArtifactCleanupQueue`", content) + self.assertNotIn("A removal failure is currently best effort", content) + + +if __name__ == "__main__": + unittest.main() From 0b43710981b4e7c9964bf251c195bcbe9d6002be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:34:30 +0900 Subject: [PATCH 147/178] docs(agents): describe integrated durable cleanup boundary --- AGENTS.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e652bf18e..e314601df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,14 +107,21 @@ Codex, Cursor, opencode, …) working in this repo. ### Durable cleanup boundary -- This administrative-authorization slice does not implement a failed-artifact - cleanup queue, deletion receipt, outbox, or retry worker. Tenant-owned job - deletion is authoritative before the current best-effort artifact removal; - a removal failure can therefore leave orphaned artifact bytes. -- Issue #263 owns the restart-safe deletion-receipt, transactional-outbox, and - cleanup-worker boundary. Do not claim that subsystem or its operational - evidence exists until its production implementation and deterministic - recovery tests are integrated. +- This slice integrates a single-process, receipt-first cleanup worker. It + forces deletion intent before the first artifact read, binds an exact digest + before metadata tombstoning, retries incomplete cleanup after startup and on + a bounded fixed-delay schedule, and exposes only low-cardinality aggregate + evidence. The filesystem-backed receipt ledger is restart-replayable; the + in-memory adapter remains available for standalone tests and ephemeral use. +- Do not overstate that reference boundary. It does not provide a cross-resource + transactional outbox, a distributed generation fence, or remote-object-store + atomicity across multiple service instances. Production adapters must provide + equivalent durable uniqueness, object-version preconditions, idempotent + retries, and operator recovery evidence before distributed cutover. +- Issue #263 remains the umbrella for the distributed adapter and later truthful + API/UI lifecycle work. Do not describe durable cleanup as absent, and do not + describe the current single-process implementation as a distributed deletion + transaction. ### Code exploration From e0bc9df1caf638fcb72e305b1c315b5d269d7831 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:35:36 +0900 Subject: [PATCH 148/178] docs(security): align authorization decision with durable cleanup --- ...2026-08-05-administrative-authorization.md | 117 +++++++++++------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index 9f859216f..6a9281f67 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -2,48 +2,63 @@ ## Decision -Clearfolio's administrative job endpoints are not global superuser APIs. They are tenant-scoped operations that evaluate signed subject claims, an explicit administrative permission, the requested operation, and the target job's tenant ownership on every request. +Clearfolio's administrative conversion-job endpoints are tenant-scoped control-plane APIs, not global superuser APIs. Every request evaluates signed subject claims, an explicit administrative permission, the requested operation, and the target job's tenant ownership. Listing requires `admin:read`; deletion and dead-letter retry require `admin:write`. Possessing an opaque UUID is never authorization. Missing and cross-tenant jobs intentionally produce the same not-found outcome so an identifier cannot enumerate another tenant's documents or operational state. -The implementation follows deny-by-default and least-privilege principles. Listing requires `admin:read`; deletion and dead-letter retry require `admin:write`. Possessing an opaque UUID is never sufficient authorization. Missing and cross-tenant jobs intentionally return the same not-found response so an object identifier cannot be used to enumerate another tenant's documents or operational state. - -Administrative endpoints never fall back to unsigned demo-header mode. If the tenant-claims HMAC verifier is absent or its configured key contains fewer than 32 UTF-8 bytes, the endpoints return `503 Service Unavailable` before repository access. This makes a missing or weak trust anchor an observable deployment failure rather than an authorization bypass. +The implementation follows deny by default and least privilege. Administrative endpoints never fall back to unsigned demo headers. If the tenant-claims HMAC verifier is absent or its key contains fewer than 32 UTF-8 bytes, the endpoints return `503 Service Unavailable` before repository access. A missing or weak trust anchor is therefore an observable deployment failure rather than an authorization bypass. ## Trust boundary -The `X-Clearfolio-*` claim headers are an internal adapter contract between Clearfolio and an authenticated gateway or host such as naruon. They are not public client credentials. The upstream gateway must authenticate the caller, construct canonical tenant, subject, permission, and issue-time claims, and sign them with the tenant-claims HMAC key. +The `X-Clearfolio-*` headers are an internal adapter contract between Clearfolio and an authenticated gateway or host such as naruon. They are not public client credentials. The upstream gateway must authenticate the caller, remove untrusted inbound copies, construct canonical tenant, subject, permission, and issue-time claims, sign them with the tenant-claims HMAC key, and forward only the verified replacement set. -Clearfolio verifies the signature and freshness before evaluating permissions. Deployments must strip untrusted inbound copies of these headers before adding verified claims. The service remains standalone because the claim verifier is an injectable component, but production must not expose the internal header adapter directly to arbitrary clients. +Clearfolio verifies signature and freshness before permission evaluation. The service remains independently deployable because the verifier is injectable, but production must not expose this internal header adapter directly to arbitrary clients. -The tenant-claims and artifact-token HMAC secrets are read from the shared Spring config-tree secret mount as `clearfolio.tenant-claims.hmac-secret` and `clearfolio.artifact-token.secret`. The buyer-demo profile no longer maps either secret-bearing environment variable directly into runtime configuration. Environment variables may select non-secret operational values or bootstrap a mounted credential store, but runtime authentication and artifact-link signing read the mounted properties. Each mounted signing key must be provisioned independently through the deployment platform's secret manager; the tenant-claims key must contain at least 32 UTF-8 bytes for privileged administrative endpoints. +Tenant-claims and artifact-token HMAC secrets are read from the shared Spring config-tree mount as `clearfolio.tenant-claims.hmac-secret` and `clearfolio.artifact-token.secret`. The buyer-demo profile does not map either secret-bearing environment variable into runtime configuration. Environment variables may select non-secret operational values or bootstrap a mounted credential source; runtime authentication and artifact-link signing read mounted properties. Each signing key must be provisioned independently through the deployment platform's secret manager. ## Authorization sequence -Every endpoint applies the same fail-closed sequence: +Every administrative endpoint applies the same fail-closed sequence: 1. Confirm that a strong signed-claim verifier is configured; otherwise return `503` before service access. -2. Parse the tenant, subject, permissions, issue time, and claim signature. -3. Verify signed claims and their freshness. +2. Parse tenant, subject, permissions, issue time, and signature. +3. Verify canonical claims, signature, and freshness. 4. Require the action-specific permission. -5. Pass the verified `TenantContext` into the object-specific service mutation. -6. Select and mutate the target through one tenant-scoped persistence operation. -7. Return a non-enumerating not-found response for absent or cross-tenant objects. -8. Emit privacy-safe authorization evidence for the resulting outcome. +5. Pass the verified `TenantContext` into the object-specific service operation. +6. Select and mutate through one tenant-scoped persistence boundary. +7. Conceal absent and cross-tenant objects with the same not-found result. +8. Emit privacy-safe authorization and lifecycle evidence. + +List responses apply the tenant predicate before job objects cross the repository boundary and then apply the optional dead-letter filter. Delete and retry do not perform controller-level read-then-write authorization. Their service contracts pass the authenticated tenant to atomic repository or state-store operations, so a non-HTTP caller cannot bypass the controller and reach an unscoped administrative mutation. + +## Immutable job and retry handoff boundary + +A missing job identifier is treated as an absent object at every tenant-scoped lookup, delete, and retry boundary. Repository adapters return an empty lookup, `false`, or `NOT_FOUND` without invoking a global lookup, changing stored state, deleting an artifact, or enqueueing work. + +Compatibility-only unscoped delete and two-argument retry methods remain available for non-administrative adapters. Their tenant-aware service, repository, and state-store defaults fail closed without reading a job or invoking legacy mutation. A production adapter must explicitly implement tenant selection and mutation in one persistence boundary. The in-memory reference adapter supplies those scoped atomic overrides. + +The content-hash secondary index is tenant-bound, and each conversion-job UUID is an immutable lifecycle identity. The in-memory adapter permanently reserves a UUID when first accepted. Saving the exact same live object may be idempotent; a distinct live object or any later object using a tombstoned UUID is rejected before secondary-index work. Deletion removes the live record and tenant-plus-content-hash index while retaining the reservation. Indexed lookup also verifies that the current UUID record matches the requested tenant and hash. -List responses apply the tenant predicate before job objects cross the repository boundary and then apply the optional dead-letter status filter. Delete and retry do not perform controller-level or service-level read-then-write authorization. Their service contracts pass the authenticated tenant to atomic repository or state-store operations, so non-HTTP callers cannot reach an unscoped administrative mutation by bypassing the controller. +Primary UUID state, permanent reservation, and the tenant-content index are updated under one shared critical section. A concurrent same-UUID save waits for the active mutation and then fails closed; deletion never releases the identifier for reuse. Durable adapters must provide the equivalent invariant with a database uniqueness constraint and durable tombstone or lifecycle-generation reservation in the same transaction. -Deletion first performs the tenant-predicate repository deletion. Artifact removal is attempted only after that owned deletion succeeds, so a missing or cross-tenant identifier cannot delete another tenant's artifact. The current removal is best effort: an artifact-store exception is logged and does not restore the already deleted job. This slice does not persist a deletion receipt, enqueue cleanup work, retry a failed removal, or expose aggregate cleanup evidence. A failure can therefore leave orphaned artifact bytes. Issue #263 owns the restart-safe deletion-receipt, transactional-outbox, idempotent cleanup-worker, privacy-safe evidence, bounded retry, and deterministic recovery boundary required for production deployment. +This invariant closes the retry-to-worker handoff race in the reference adapter even though the worker queue carries a UUID. After an authorized tenant retry transitions the original object, another tenant cannot replace that UUID before enqueue or worker lookup. Distributed adapters must additionally bind queued work to tenant and immutable generation, then revalidate both at execution time. -Retry receives one atomic state-store outcome: `ACCEPTED`, concealed `NOT_FOUND`, or `NOT_ELIGIBLE`. The worker is enqueued only after the state store has atomically verified ownership and moved the owned dead-lettered job back to submitted state. +## Durable artifact-deletion boundary -A missing job identifier is treated as an absent object at every tenant-scoped lookup, delete, and retry boundary. Repository adapters return an empty lookup, `false`, or `NOT_FOUND` without invoking a global lookup, mutating stored state, deleting an artifact, or enqueueing work. This keeps internal and modular callers on the same non-enumerating contract even when a malformed or incomplete adapter call bypasses HTTP path binding. +Authorized deletion is routed through `DurableDocumentDeletionService` and `ArtifactDeletionCoordinator`. The reference runtime now implements a durable deletion receipt and bounded cleanup lifecycle rather than best-effort-only removal. -The historical unscoped delete method and two-argument retry method remain compatibility contracts for non-administrative adapters only. Their tenant-aware service, repository, and state-store defaults fail closed without reading a job or invoking either legacy mutation. A production adapter must explicitly override the tenant-aware methods and perform tenant selection and mutation within one persistence boundary before an administrative request can succeed. Clearfolio's in-memory reference implementation supplies those scoped atomic overrides. +The lifecycle is receipt first: -The content-hash secondary index is tenant-bound, and each conversion-job UUID is an immutable lifecycle identity. The in-memory adapter reserves a UUID when it first accepts a job. Saving the exact same live object may be idempotent, but any distinct live object or any later object using a deleted UUID is rejected before secondary-index work. Deletion removes the live record and its tenant-and-content-hash index while retaining the UUID reservation. Lookup also validates that the current UUID record matches the requested tenant-and-hash key. These rules prevent a queued UUID, stale index, or stale preliminary observation from being rebound to another tenant's job. +1. Under the per-job lifecycle lock, persist `DELETION_REQUESTED` with a controlled non-digest `pending` checksum marker before the first artifact-store read. +2. If the first read fails, preserve metadata, retain the durable receipt, record only a controlled low-cardinality failure, and allow startup or scheduled recovery to resume. +3. After a successful read, bind the same receipt identity exactly once to the artifact SHA-256 or the explicit confirmed-absence digest. Replay rejects reverse or conflicting binding. +4. Only after exact checksum binding may the tenant-scoped metadata tombstone be applied. +5. Mark cleanup pending, compare the exact generation, delete the artifact idempotently, and record completion or a controlled retryable failure. +6. Replay incomplete receipts at application startup and on a bounded fixed-delay schedule. -The in-memory adapter updates the primary UUID map, the permanent identifier reservation, and the tenant-content secondary index under one shared critical section for save, find-or-store, indexed lookup, tenant-scoped deletion, and compatibility deletion. A concurrent same-UUID save therefore waits for the active operation and then fails closed; deletion never releases the identifier for reuse. Durable adapters must provide the equivalent invariant with a database uniqueness constraint plus a durable tombstone or lifecycle-generation reservation in the same transaction. Process-local locking is a reference-adapter mechanism, not an interoperability contract. +`LifecycleFencedArtifactStore` shares the per-job lifecycle lock and rejects publication after a deletion receipt exists. An in-flight publication that completes before receipt acceptance becomes the captured generation and is deleted; a later publication fails closed. Aggregate metrics expose only completed, failed, pending, and bounded execution evidence. They do not use tenant, job, digest, path, filename, exception message, or other high-cardinality dimensions. -This identifier rule closes the retry-to-worker handoff race even though the current in-memory worker queue carries a UUID: after an authorized tenant retry transitions the original object, another tenant cannot replace that UUID before enqueue or worker lookup. The durable lifecycle work tracked in issue #263 will strengthen this boundary further with tenant-and-generation deletion receipts and outbox records, but it must not be used to justify a tenant-crossing retry path in the current slice. +The append-only file adapter forces each accepted receipt snapshot before reporting it durable. It supports restart replay and preserves incomplete cleanup evidence. The in-memory adapter remains available for standalone tests and ephemeral use. + +This reference boundary is intentionally narrower than a distributed transaction. It has no cross-resource transactional outbox spanning an external database and object store, no distributed generation fence shared by multiple service instances, and no remote-object-store atomicity guarantee. A multi-instance production adapter must add durable uniqueness, object-version preconditions or compare-and-set semantics, an idempotent outbox/consumer boundary, bounded retry and dead-letter operations, and operator recovery evidence. Issue #263 remains the umbrella for those distributed adapters and later truthful API/UI deletion-state semantics. ## Audit evidence @@ -52,12 +67,29 @@ Administrative evidence contains only: - a controlled action code; - a controlled outcome code; - HTTP status; -- tenant, actor, and conversion-job HMAC fingerprints in three separate domains; -- a numeric result count for list operations. +- tenant, actor, and conversion-job HMAC fingerprints in separate domains; +- a numeric result count for list operations; +- low-cardinality deletion lifecycle counts and controlled failure codes. + +It excludes raw tenant identifiers, raw subject identifiers, raw job UUIDs, claim signatures, permission headers, filenames, job messages, document text, storage paths, exception-controlled text, and artifact bytes. Job correlation uses a dedicated keyed HMAC domain so resource references cannot be joined directly with API paths, databases, support exports, or external telemetry. Retry provenance stores the actor-domain fingerprint rather than the source subject. Pseudonymized values remain personal data and inherit the retention, access, rotation, and incident-response requirements in `2026-08-04-audit-pseudonymization.md`. + +Operators must distinguish the persisted receipt ledger from ordinary application logs. The ledger and aggregate metrics provide durable single-process cleanup evidence; logs are diagnostic only and never substitute for receipt state. + +## Standalone and modular deployment + +`ArtifactDeletionReceiptStore`, `ArtifactStore`, `ConversionJobRepository`, and the tenant-aware service contracts are replaceable boundaries. The reference filesystem ledger and artifact store permit standalone operation. naruon or another CWL host may replace them with shared durable adapters without changing the authorization, immutable identity, receipt state machine, or privacy-safe evidence contracts. -It does not contain raw tenant identifiers, raw subject identifiers, raw job UUIDs, claim signatures, permission headers, filenames, job messages, document text, or artifact bytes. Job correlation uses a dedicated keyed HMAC domain so resource references cannot be joined directly with API paths, databases, support exports, or external telemetry. The retry provenance stored with a job uses the actor-domain fingerprint rather than the source subject identifier. Pseudonymized values remain personal data and inherit the retention, access, rotation, and incident-response requirements in `2026-08-04-audit-pseudonymization.md`. +A modular deployment must preserve: -No durable artifact-cleanup evidence exists in this slice. Operators must not interpret an application log entry as a persisted deletion receipt or retry record. Buyer-facing and production-readiness materials must keep issue #263 open until a durable cleanup adapter and recovery evidence are integrated. +- signed tenant context before service access; +- tenant-scoped atomic mutation; +- permanent UUID or immutable generation identity; +- receipt-before-read ordering; +- one-way exact-digest binding before metadata tombstone; +- conversion/deletion generation fencing; +- idempotent retry and recovery; +- low-cardinality, privacy-safe evidence; +- fail-closed behavior when a required adapter cannot prove those invariants. ## Verification requirements @@ -66,25 +98,24 @@ Automated tests must exercise the real signed-claim verifier and prove: - absent and weak verifier keys make privileged endpoints unavailable before service access; - missing, malformed, expired, and incorrectly signed claims fail before service access; - missing `admin:read` or `admin:write` permissions fail before service access; -- list results contain only tenant-owned jobs for all dead-letter filter states; +- list results contain only tenant-owned jobs for every dead-letter filter state; - missing and cross-tenant delete/retry targets produce indistinguishable not-found responses; -- a missing job identifier fails closed across tenant-scoped lookup, delete, and retry while leaving existing stored state unchanged; -- delete and retry cross tenant-aware persistence boundaries without a separate lookup or unscoped mutation call; -- failed tenant-scoped deletion never touches the artifact store, while successful owned deletion attempts artifact removal only after repository authorization; -- an artifact-removal failure does not roll back the authorized job deletion and is not represented as durable cleanup evidence in this slice; -- compatibility-only service, repository, and state-store adapters cannot reach global lookup, delete, or retry methods through tenant-aware defaults; -- saving the exact same live object is idempotent, while a distinct object with a live UUID is rejected without changing tenant, hash-index, or lifecycle ownership; -- deletion removes the live job and secondary index but leaves the UUID reserved, so both direct save and atomic find-or-store reject later reuse; +- missing job identifiers fail closed without global lookup or mutation; +- delete and retry cross tenant-aware persistence boundaries without a separate unscoped lookup; +- compatibility adapters cannot reach global lookup, delete, or retry through tenant-aware defaults; +- exact same-object save is idempotent while distinct live or tombstoned UUID reuse is rejected; +- deletion removes the live job and secondary index but preserves UUID reservation; - a colliding find-or-store candidate fails before index ownership changes; -- a concurrent tenant-scoped delete cannot release or transfer UUID ownership, and a waiting same-UUID save fails before replacement index work; -- an accepted tenant retry cannot be followed by a same-UUID cross-tenant replacement before enqueue or worker lookup; -- stale observations cannot delete or retry a job owned by another tenant; -- the durable retry state store rejects invalid, missing, cross-tenant, and ineligible targets without an unauthorized transition or worker enqueue; -- accepted retry provenance is a domain-separated keyed fingerprint, never a raw or unkeyed subject value; -- not-found, not-eligible, repository failure, artifact-removal failure, and retry failure paths return stable non-leaking responses; -- both artifact-token and tenant-claims signing keys are sourced from the shared config-tree mount rather than secret-valued profile environment placeholders; -- audit output contains no raw tenant, subject, job UUID, signature, filename, or document data; -- JaCoCo reports 100% line and branch coverage for the `com.clearfolio.viewer.*` production package. +- concurrent delete/save and retry/replacement interleavings preserve tenant and generation ownership; +- accepted retry provenance is a domain-separated keyed fingerprint; +- the durable deletion receipt is accepted before the first artifact read; +- first-read failure preserves metadata and restart-safe pending evidence rather than claiming confirmed absence; +- exact digest binding is one-way, durable, replay-validated, and required before metadata tombstone; +- successful cleanup, read failure, digest mismatch, delete failure, retry, restart, and bounded-batch recovery are deterministic; +- write-after-receipt and in-flight publication races cannot recreate deleted confidential bytes; +- cleanup evidence contains no raw UUID, tenant, path, digest dimension, exception text, or attached throwable; +- both signing keys are sourced from the shared config-tree mount; +- JaCoCo reports zero missed production lines and branches and public Javadocs pass with no warnings. ## References From 1356e5ddd954b54f983f76dc1e360fa8e0689a6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:37:17 +0900 Subject: [PATCH 149/178] docs(deployment): document durable cleanup operation and limits --- ...2-buyer-deployment-integration-playbook.md | 347 +++++++----------- 1 file changed, 138 insertions(+), 209 deletions(-) diff --git a/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md b/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md index 331e7f42c..827c11661 100644 --- a/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md +++ b/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md @@ -1,57 +1,41 @@ # Buyer Deployment and Integration Playbook Date: 2026-07-02 -Last updated: 2026-08-06 - -This playbook turns the current Clearfolio Viewer sale-readiness slice into a -repeatable buyer sandbox deployment. It is intentionally scoped to the current -runtime: Spring Boot, WebFlux JSON APIs, buyer-demo shell, gateway-signed tenant -headers, signed artifact links, local append-only evidence ledgers, and the -mandatory repository gates. It does not use Figma Code Connect and does not -claim production OIDC/JWT, durable database, durable object storage, or external -legal sign-off. - -## Buyer-Ready Claim Boundary - -The deployment can prove: - -- a buyer can run the upload, conversion, preview, KPI, and operator recovery - demo without adding a frontend framework; -- tenant-scoped JSON APIs can require gateway-signed Clearfolio headers when a - shared HMAC secret is configured; -- preview artifacts require signed artifact tokens, not bare document ids; -- tenant-claim and artifact-token signing keys are read from a mounted Spring - config tree rather than secret-valued runtime environment placeholders; -- issued links, revocations, artifact reads, and KPI exports can survive a - single-process restart through local append-only evidence ledgers; -- the same Maven, JavaDoc, coverage, Markdown, SAST, SBOM, and license-policy - gates remain attached to the exact current PR head. - -The deployment cannot yet prove: - -- production OIDC/JWT issuer, audience, expiry, `kid`, and role validation; -- centralized durable job, artifact, revocation, audit, analytics, or failed - artifact-cleanup storage; -- restart-safe deletion receipts, transactional cleanup outbox processing, - bounded retry, or deterministic orphan-artifact recovery evidence; -- final legal review of the attribution and redistribution package; -- a packaged Power Platform connector. - -## Runtime Profile - -Use the `buyer-demo` Spring profile for a buyer sandbox. Runtime key material is -loaded from a Spring Boot config-tree mount. `CLEARFOLIO_SECRET_CONFIG_DIR` -selects that mount and is not itself secret. The mounted files -`clearfolio.tenant-claims.hmac-secret` and -`clearfolio.artifact-token.secret` must be provisioned independently through the -deployment platform's secret manager in shared environments. The tenant-claims -key must contain at least 32 UTF-8 bytes for privileged administrative APIs. - -For a local sandbox, create owner-readable config-tree files before startup: +Last updated: 2026-08-07 + +This playbook turns the current Clearfolio Viewer sale-readiness slice into a repeatable buyer sandbox. The runtime is Spring Boot and WebFlux with a browser shell, gateway-signed tenant headers, signed artifact links, filesystem-backed artifacts, append-only evidence ledgers, a receipt-first deletion worker, and mandatory exact-head repository gates. It does not claim production OIDC/JWT, a distributed database/object-store transaction, external legal sign-off, or a packaged Power Platform connector. + +## Buyer-ready claim boundary + +The current sandbox can prove: + +- document upload, asynchronous conversion, status polling, preview, KPI evidence, dead-letter retry, and tenant-scoped administration; +- gateway-signed tenant claims with action-specific least privilege; +- tenant-owned document bytes require dedicated `artifact:read`, while preview links require signed tenant-bound artifact tokens; +- signing keys come from a mounted Spring config tree rather than secret-valued runtime environment placeholders; +- filesystem artifacts, issued-link evidence, revocations, artifact reads, KPI exports, and deletion receipts survive a single-process restart; +- authorized deletion persists intent before the first artifact read, binds an exact generation before metadata tombstoning, and performs bounded scheduled recovery after cleanup failure; +- exact-head Maven, Javadoc, coverage, script, SAST, SBOM, attribution, and license-policy gates remain fail closed. + +The sandbox cannot yet prove: + +- production OIDC/JWT issuer, audience, expiry, `kid`, rotation, and role validation; +- centralized durable job, artifact, revocation, audit, and analytics storage across multiple instances; +- a cross-resource transactional outbox spanning a durable database and remote object store; +- a distributed generation fence or remote-object-store atomicity; +- final legal review of attribution and redistribution evidence; +- a buyer-specific packaged connector. + +## Runtime profile + +Use the `buyer-demo` Spring profile. Runtime key material is loaded from a Spring Boot config-tree mount. `CLEARFOLIO_SECRET_CONFIG_DIR` selects that mount and is not secret. Provision the mounted files `clearfolio.tenant-claims.hmac-secret` and `clearfolio.artifact-token.secret` independently through the deployment platform's secret manager. The tenant-claims key must contain at least 32 UTF-8 bytes for privileged administrative APIs. + +For a local sandbox: ```bash umask 077 mkdir -p .clearfolio/buyer-demo/secrets +mkdir -p .clearfolio/buyer-demo/data/artifacts openssl rand -base64 48 \ > .clearfolio/buyer-demo/secrets/clearfolio.tenant-claims.hmac-secret openssl rand -base64 48 \ @@ -61,34 +45,38 @@ export SPRING_PROFILES_ACTIVE=buyer-demo export CLEARFOLIO_SECRET_CONFIG_DIR="$PWD/.clearfolio/buyer-demo/secrets/" export CLEARFOLIO_ARTIFACT_LINK_LEDGER_PATH="$PWD/.clearfolio/buyer-demo/artifact-link-ledger.log" export CLEARFOLIO_ANALYTICS_SNAPSHOT_LEDGER_PATH="$PWD/.clearfolio/buyer-demo/kpi-snapshot-ledger.log" +export CLEARFOLIO_ARTIFACT_DELETION_LEDGER_PATH="$PWD/.clearfolio/buyer-demo/artifact-deletion-receipts.log" +export CLEARFOLIO_ARTIFACT_STORE_ROOT_DIR="$PWD/.clearfolio/buyer-demo/data/artifacts" export CLEARFOLIO_FRAME_ANCESTORS="self" mvn spring-boot:run ``` -The profile file is -`src/main/resources/application-buyer-demo.yml`. Non-secret operational settings -may use environment variables. Neither HMAC key is bound from a secret-valued -runtime environment variable. Both are read from the shared config-tree import -in `application.yml`; the environment selects only the bootstrap directory. -`CLEARFOLIO_TENANT_CLAIMS_MAX_SKEW_SECONDS` remains a non-secret runtime setting. +Spring relaxed binding maps the non-secret path variables to the corresponding `clearfolio.*` properties. The repository defaults remain `data/artifacts` for PDF bytes and `data/artifact-deletion-receipts.log` for deletion receipt snapshots. The receipt file and artifact root must reside on durable owner-restricted storage in shared environments. Do not put either path on an ephemeral container layer when restart recovery is required. + +`src/main/resources/application-buyer-demo.yml` may use environment variables for non-secret operational settings. Neither HMAC key is bound from a secret-valued environment placeholder. Both are read from the common config-tree import in `application.yml`; the environment selects only the bootstrap directory. + +## Durable deletion and recovery operation + +Tenant-owned deletion is routed through `DurableDocumentDeletionService` and `ArtifactDeletionCoordinator`. -An owned repository deletion precedes the current best-effort artifact removal. -If the artifact store rejects that removal, the job remains deleted and orphaned -artifact bytes can remain. This slice does not configure `ArtifactCleanupQueue`, -a retry cadence, deletion receipts, a cleanup outbox, or aggregate cleanup -metrics. Issue #263 owns the restart-safe cleanup subsystem and its deterministic -recovery evidence; production cutover remains blocked until that work is -integrated. +1. The service acquires the shared per-job lifecycle lock. +2. It forces a durable receipt with a controlled `pending` checksum marker before the first artifact read. +3. If that read fails, metadata remains live and the receipt remains recoverable. The failure is represented by a controlled low-cardinality code, not exception text. +4. A successful read binds the same receipt identity one way to the exact SHA-256 digest, or to the explicit confirmed-absence digest when the artifact is truly absent. +5. Only then may tenant-scoped metadata be tombstoned. +6. Cleanup compares the bound generation, deletes idempotently, and records completion or retryable failure. +7. Startup recovery and the fixed-delay worker process a bounded batch of incomplete receipts. -For a Power Platform embedding test, replace `CLEARFOLIO_FRAME_ANCESTORS` with -the exact buyer allowlist after the gateway hostname is known. Keep it narrow; -do not use a wildcard until a security owner explicitly accepts that risk. +Defaults are a 30-second retry delay, a maximum of 100 receipts per pass, and a Spring scheduler pool of two threads. Per-job locks serialize the same generation while unrelated jobs can progress. Aggregate evidence is limited to completed, failed, pending, and execution-duration values; it carries no tenant, job, digest, path, filename, or exception-controlled dimension. -## Gateway Claim Contract +`LifecycleFencedArtifactStore` prevents a conversion worker from publishing another artifact after receipt acceptance. An in-flight publication that wins the lock first becomes the captured generation and is removed; publication after receipt acceptance fails closed. -When the mounted `clearfolio.tenant-claims.hmac-secret` property is present, -every protected JSON API call must include: +This is a restart-safe single-process reference adapter, not a distributed transaction. Multi-instance production deployment remains blocked until adapters provide a cross-resource transactional outbox or equivalent idempotent consumer, durable uniqueness and tombstones, a distributed generation fence, object-version preconditions, remote-object-store atomicity or safely compensating semantics, bounded dead-letter recovery, and operator evidence. + +## Gateway claim contract + +When `clearfolio.tenant-claims.hmac-secret` is mounted, protected JSON APIs require: - `X-Clearfolio-Tenant-Id` - `X-Clearfolio-Subject-Id` @@ -96,7 +84,7 @@ every protected JSON API call must include: - `X-Clearfolio-Claims-Issued-At` - `X-Clearfolio-Claims-Signature` -The HMAC payload is the exact newline-joined string: +The HMAC payload is the exact newline-joined form: ```text tenantId @@ -105,111 +93,83 @@ canonicalPermissions issuedAt ``` -The signature is Base64URL HMAC-SHA256 without padding. The default timestamp -skew window is 300 seconds and can be set with -`CLEARFOLIO_TENANT_CLAIMS_MAX_SKEW_SECONDS`. +The signature is Base64URL HMAC-SHA256 without padding. The default timestamp skew is 300 seconds and is configurable with `CLEARFOLIO_TENANT_CLAIMS_MAX_SKEW_SECONDS`. -`canonicalPermissions` is **not** the raw `X-Clearfolio-Permissions` header -value — the verifier re-derives it before checking the signature, so a buyer -gateway must sign the same derived form or every request returns `401`. The -derivation (see `TenantContext.permissionsOf` / `canonicalPermissions`) is: +The verifier derives `canonicalPermissions` by splitting on commas, removing NUL, stripping whitespace, dropping empty values, de-duplicating in first-seen order, and joining with commas. Tenant and subject values are sanitized before verification as well. The gateway must send and sign canonical values, not a differently formatted raw header. -1. split the header on `,`; -2. sanitize each entry — remove NUL, `strip()` surrounding whitespace, drop empties; -3. de-duplicate **preserving first-seen order** (backed by a `LinkedHashSet`); -4. re-join with `,`. +The authenticated gateway must strip untrusted inbound `X-Clearfolio-*` headers before mapping the principal, constructing claims, signing, and forwarding. Browsers and external API clients are not trusted issuers. -`tenantId` and `subjectId` are sanitized the same way before signing. So a -gateway must send **and sign** already-canonical values: e.g. -`" viewer:read , job:read,viewer:read "` must be signed as -`viewer:read,job:read`. Sign what the verifier will re-derive, not the raw -string. +Example user permission set: -The authenticated gateway must remove all untrusted inbound -`X-Clearfolio-*` claim headers before it maps the authenticated principal, -constructs canonical claims, signs them, and forwards the replacement header -set. Browsers and external API clients are not trusted claim issuers. +```text +job:create,job:read,job:retry,viewer:read,artifact:read,artifact-link:create,analytics:read +``` -Buyer-demo permission set: +Example tenant operator additions: ```text -job:create,job:read,job:retry,viewer:read,artifact-link:create,analytics:read +admin:read,admin:write ``` -Production role mapping should later replace this scaffold with validated -gateway or OIDC claims. Do not hand-roll JWT parsing in this service. - -For any environment that sets `SPRING_PROFILES_ACTIVE=production`, the service -fails startup unless the config-tree mount supplies a sufficiently strong -`clearfolio.tenant-claims.hmac-secret`. Setting only -`CLEARFOLIO_SECRET_CONFIG_DIR` without the required secret file does not enable -signed claims. The buyer-demo profile can still run unsigned for local -screenshots, but production cannot accidentally inherit that unsigned mode. -Artifact-link issuance likewise reads `clearfolio.artifact-token.secret` from -the same mount; production operators must provision a distinct strong key and -must not restore the retired `CLEARFOLIO_ARTIFACT_TOKEN_SECRET` runtime binding. - -## Integration Flow - -1. Buyer browser, Power Platform, or internal workflow authenticates at the - buyer-controlled gateway. -2. Gateway strips untrusted inbound Clearfolio claim headers, maps the principal - to Clearfolio tenant id, subject id, and permissions, and canonicalizes the - mapped values. -3. Gateway signs the canonical Clearfolio headers and forwards requests to - `POST /api/v1/convert/jobs`, status, viewer bootstrap, retry, artifact-link, - and analytics APIs. -4. Clearfolio verifies the signed headers, enforces permissions, and hides - cross-tenant resources. -5. The buyer-demo shell shows the upload, status, KPI evidence, and operator - recovery path at `GET /`. -6. Viewer bootstrap returns a signed `previewResourcePath`. -7. PDF.js reads `/artifacts/{docId}.pdf` only with a valid artifact token. -8. Optional local ledgers capture issued links, revocations, artifact reads, - and KPI snapshot exports for buyer evidence. -9. An owned job deletion attempts artifact removal only after tenant-scoped - repository deletion. A removal failure is currently best effort, can leave - orphaned bytes, and is not a durable retry or operational-evidence record. - -## API Surface for a Connector +Production role mapping must later replace this scaffold with validated gateway or OIDC claims. Do not hand-roll JWT parsing in this service. + +For `SPRING_PROFILES_ACTIVE=production`, startup fails unless the config-tree mount supplies a sufficiently strong tenant-claims key. Setting only the bootstrap directory does not enable signed claims. Artifact-link issuance reads a distinct strong `clearfolio.artifact-token.secret`; do not restore the retired direct environment binding. + +## Integration flow + +1. A buyer browser, Power Platform client, or internal workflow authenticates at the buyer-controlled gateway. +2. The gateway strips untrusted Clearfolio headers, maps the principal to tenant, subject, and permissions, canonicalizes them, signs them, and forwards the request. +3. Clearfolio verifies the signature, freshness, action permission, and tenant ownership. +4. `POST /api/v1/convert/jobs` creates a durable asynchronous job identity and the worker produces a fenced PDF generation. +5. Status and viewer bootstrap remain tenant scoped; direct download additionally requires `artifact:read`. +6. PDF.js reads only a same-origin artifact authorized by a valid tenant-bound token. +7. Optional ledgers capture issued links, revocations, reads, KPI exports, and deletion receipts. +8. Tenant-owned deletion persists intent, binds the artifact generation, tombstones metadata, and completes or retries physical cleanup. +9. Restart with the same paths replays the append-only evidence and resumes bounded incomplete cleanup. + +## API surface for a connector | Connector step | Endpoint | Permission | Buyer evidence | | --- | --- | --- | --- | -| Submit document | `POST /api/v1/convert/jobs` | `job:create` | Returns `202`, `jobId`, `statusUrl`. | -| Poll lifecycle | `GET /api/v1/convert/jobs/{jobId}` | `job:read` | Shows status, attempts, retry time, dead-letter state. | -| Open viewer | `GET /viewer/{docId}` | HTML shell | Protected JSON APIs decide visible state. | -| Bootstrap preview | `GET /api/v1/viewer/{docId}` | `viewer:read` | Returns short-lived signed artifact URL. | +| Submit document | `POST /api/v1/convert/jobs` | `job:create` | Returns `202`, `jobId`, and `statusUrl`. | +| Poll lifecycle | `GET /api/v1/convert/jobs/{jobId}` | `job:read` | Shows status, attempts, retry time, and dead-letter state. | +| Download result | `GET /api/v1/convert/jobs/{jobId}/download` | `artifact:read` | Returns tenant-owned PDF bytes only. | +| Open viewer | `GET /viewer/{docId}` | HTML shell | Protected JSON APIs determine visible data. | +| Bootstrap preview | `GET /api/v1/viewer/{docId}` | `viewer:read` | Returns a short-lived signed artifact URL. | | Create artifact link | `POST /api/v1/viewer/{docId}/artifact-links` | `artifact-link:create` | Produces tenant-bound token metadata. | -| Retry dead letter | `POST /api/v1/convert/jobs/{jobId}/retry` | `job:retry` | Shows operator recovery evidence. | -| Read KPIs | `GET /api/v1/analytics/kpi-snapshot` | `analytics:read` | Shows runtime job count, success rate, P95 preview. | -| Read KPI exports | `GET /api/v1/analytics/kpi-snapshot-exports` | `analytics:read` | Shows exported buyer evidence without tenant id. | +| Retry dead letter | `POST /api/v1/convert/jobs/{jobId}/retry` | `job:retry` | Records operator recovery evidence. | +| List tenant jobs | `GET /api/v1/admin/convert/jobs` | `admin:read` | Returns only the authenticated tenant's scope. | +| Delete tenant job | `DELETE /api/v1/admin/convert/jobs/{jobId}` | `admin:write` | Enters the durable deletion lifecycle. | +| Retry tenant job | `POST /api/v1/admin/convert/jobs/{jobId}/retry` | `admin:write` | Uses an atomic tenant-scoped retry transition. | +| Read KPIs | `GET /api/v1/analytics/kpi-snapshot` | `analytics:read` | Shows bounded operational evidence. | -## Buyer Sandbox Smoke +## Buyer sandbox smoke test -After the app starts, run: +After startup: ```bash curl -sS http://localhost:8080/healthz curl -sS http://localhost:8080/ | head ``` -Then complete one browser flow: +Complete one realistic browser and operator flow: -1. Open `http://localhost:8080/`. -2. Upload a small supported document. -3. Confirm the status row reaches `SUCCEEDED`. -4. Open the viewer link. -5. Confirm the KPI strip and KPI snapshot evidence panel update. -6. Trigger or inspect operator recovery evidence through a failed or - dead-lettered job when available. -7. Restart the app with the same ledger paths and confirm exported evidence - records are replayed. +1. Upload a small supported office document. +2. Confirm the status reaches `SUCCEEDED`. +3. Open the signed viewer and direct-download path with the correct permissions. +4. Confirm the KPI evidence updates. +5. Exercise a dead-letter retry when available. +6. Delete an owned job and confirm a receipt snapshot is appended. +7. Inject one artifact-store failure in a controlled test environment and confirm metadata is not lost before exact digest binding and incomplete cleanup remains pending. +8. Restart with the same artifact and ledger paths, then confirm bounded recovery completes the cleanup. +9. Confirm cross-tenant UUID requests remain concealed and do not touch artifact storage. -For exact-head engineering evidence, use the same fail-closed lifecycle as CI: +For exact-head engineering evidence: ```bash mvn -B --no-transfer-progress verify python3 scripts/verify_maven_test_reports.py +python3 -m unittest discover -s scripts python3 scripts/check_sbom_license_policy.py \ --sbom docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json \ --policy docs/security/2026-07-02-license-policy.json \ @@ -222,78 +182,47 @@ python3 scripts/check_buyer_dataroom_manifest.py \ --manifest docs/diligence/2026-07-03-buyer-data-room-manifest.json ``` -Do not substitute `mvn test`, a standalone Javadoc invocation, or a predecessor -head. Buyer-release mode must keep `--require-no-review` enabled so any future -review-required component fails before buyer handoff. The attribution `--check` -must also pass so the buyer data-room notice matches the current SBOM. +Do not substitute `mvn test`, a standalone Javadoc invocation, a synthetic-only result, or predecessor-head evidence. Buyer-release mode keeps `--require-no-review` enabled. -## Diligence Handoff Checklist +## Diligence handoff checklist Before a buyer sandbox is shown, attach: -- the authoritative PR URL and exact current head SHA; +- the authoritative PR URL and exact head SHA; - `docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md`; - `docs/diligence/2026-07-03-buyer-data-room-manifest.json`; -- this playbook; -- `docs/deployment/clearfolio-buyer-connector.openapi.yaml`; +- this playbook and `docs/deployment/clearfolio-buyer-connector.openapi.yaml`; - `src/main/resources/application-buyer-demo.yml`; - `docs/security/2026-07-02-auth-tenant-model.md`; - `docs/security/2026-07-02-signed-artifact-link-design.md`; +- `docs/security/2026-08-05-administrative-authorization.md`; +- `docs/security/2026-08-06-durable-artifact-deletion-receipts.md`; - `docs/legal/2026-07-03-third-party-attribution.md`; - `docs/analytics/2026-07-02-durable-metrics-event-model.md`; -- FigJam board: - . - -The FigJam board includes `Clearfolio Buyer Integration Deployment Flow`, which -mirrors this playbook's gateway, runtime, evidence-ledger, and diligence -artifact path. Figma Code Connect is not used. - -## Connector Seed - -The repository includes `docs/deployment/clearfolio-buyer-connector.openapi.yaml` -as an OpenAPI 3.0 import seed for a buyer-owned gateway or Power Platform custom -connector. It covers: - -- document submission through multipart upload; -- conversion status polling; -- dead-letter retry; -- viewer bootstrap with signed artifact URL; -- signed artifact-link creation and revocation; -- artifact read audit lookup; -- KPI snapshot and KPI export lookup. - -The seed intentionally models the current signed Clearfolio tenant-header -scaffold. It is not a validated production OIDC/JWT connector profile, and it -has not been imported into a buyer Power Platform tenant. A buyer-specific -connector package should be created only after the gateway hostname, OIDC issuer, -role mapping, and `frame-ancestors` allowlist are known. - -## Library and Submodule Decision - -No separate library, submodule, or Maven multi-module split is justified for -this deployment slice. The profile and playbook reduce buyer integration cost -without introducing versioning, release, or source-of-truth overhead. Revisit a -split only after a packaged connector, SDK, or second service consumes the same -contracts independently. - -## Production Cutover Gates - -The buyer sandbox should not be promoted to production until these gates close: - -- buyer-release license-policy evidence remains green with - `--require-no-review`, attribution drift check remains green, and final legal - release review is obtained; -- `SPRING_PROFILES_ACTIVE=production` starts only when the config-tree mount - contains distinct strong `clearfolio.tenant-claims.hmac-secret` and - `clearfolio.artifact-token.secret` files, and later replaces the claim scaffold - with validated OIDC/JWT claims; -- validated gateway or OIDC JWT issuer, audience, expiry, key rotation, and role - mapping; -- durable conversion job repository with persisted state transitions and - permanent UUID tombstone or lifecycle-generation uniqueness; -- durable object store metadata, token revocation, artifact read audit, and - restart-safe artifact-cleanup queue or transactional outbox; -- durable metrics event stream and daily KPI projections; -- buyer-specific `frame-ancestors` allowlist and security owner approval; -- connector seed imported and tested against the buyer's actual gateway and - Power Platform tenant. +- the FigJam `Clearfolio Buyer Integration Deployment Flow` board. + +The FigJam board mirrors the gateway, runtime, evidence-ledger, and diligence flow. Figma Code Connect is not used by this deployment slice. + +## Connector seed + +`docs/deployment/clearfolio-buyer-connector.openapi.yaml` is an OpenAPI 3.0 import seed for a buyer-owned gateway or Power Platform custom connector. It covers submission, status, retry, viewer bootstrap, signed-link creation and revocation, artifact-read audit lookup, and KPI evidence. It models the current signed-header adapter; it is not a validated production OIDC/JWT connector and has not been imported into a buyer tenant. + +A buyer-specific package should be created only after gateway hostname, OIDC issuer, role mapping, deletion-status API contract, and `frame-ancestors` allowlist are known. + +## Standalone and modular decision + +No Maven multi-module split is required for this sandbox. The runtime contracts are injectable: a standalone deployment uses the filesystem adapters, while naruon or another CWL host can replace repository, artifact, receipt, audit, and claim adapters. Replacement adapters must preserve tenant scoping, immutable generation identity, receipt-before-read ordering, one-way digest binding, generation fencing, idempotent recovery, and privacy-safe evidence. + +## Production cutover gates + +Do not promote the buyer sandbox until all of these gates close: + +- exact-head CI, Security Scan, SAST, fuzz, complete coverage, warning-free Javadocs, SBOM, provenance, attribution, and buyer-release license policy are successful; +- final independent write-authorized review and legal review are complete; +- production config-tree mounts contain distinct strong signing keys; +- validated OIDC/JWT issuer, audience, expiry, rotation, and role mapping replace the header scaffold; +- a durable conversion-job repository enforces persisted state transitions and permanent generation uniqueness; +- the object store, token revocation, read audit, and deletion receipt/outbox adapters are durable and multi-instance safe; +- distributed generation fencing, object-version preconditions, remote-object-store recovery, dead-letter operations, and restore/rollback drills are evidenced; +- metrics and OpenTelemetry evidence are privacy safe and operationally actionable; +- the buyer-specific `frame-ancestors` allowlist and connector package are tested against the actual gateway and tenant. From 98a85892080a3bb2e4433ee7653461c559ba212a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:42:09 +0900 Subject: [PATCH 150/178] test(docs): make cleanup claims formatting-independent --- ..._durable_cleanup_documentation_contract.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/test_durable_cleanup_documentation_contract.py b/scripts/test_durable_cleanup_documentation_contract.py index 702738424..1c7161aeb 100644 --- a/scripts/test_durable_cleanup_documentation_contract.py +++ b/scripts/test_durable_cleanup_documentation_contract.py @@ -27,17 +27,32 @@ ) +def normalized_text(path: Path) -> str: + """Read UTF-8 Markdown and collapse formatting whitespace for claims. + + Markdown paragraphs are wrapped for readability, so a stable semantic + contract must not depend on whether a phrase crosses a source line break. + + Args: + path: Repository document whose claims are being verified. + + Returns: + Text with every whitespace run represented by one ASCII space. + """ + return " ".join(path.read_text(encoding="utf-8").split()) + + class DurableCleanupDocumentationContractTest(unittest.TestCase): """Keeps buyer and operator claims aligned with the integrated runtime.""" def test_agent_guide_describes_integrated_worker_and_remaining_limits(self) -> None: """The agent source of truth must not describe cleanup as absent.""" - content = AGENT_GUIDE.read_text(encoding="utf-8") + content = normalized_text(AGENT_GUIDE) self.assertIn("receipt-first cleanup worker", content) self.assertIn("cross-resource transactional outbox", content) self.assertNotIn( - "does not implement a failed-artifact\n cleanup queue, deletion receipt, outbox, or retry worker", + "does not implement a failed-artifact cleanup queue, deletion receipt, outbox, or retry worker", content, ) @@ -45,7 +60,7 @@ def test_authorization_decision_distinguishes_local_durability_from_distributed_ self, ) -> None: """The security decision must state both implemented and residual risk.""" - content = AUTHORIZATION_DECISION.read_text(encoding="utf-8") + content = normalized_text(AUTHORIZATION_DECISION) self.assertIn("durable deletion receipt", content) self.assertIn("bounded scheduled recovery", content) @@ -57,7 +72,7 @@ def test_deployment_playbook_exposes_recovery_configuration_and_cutover_limits( self, ) -> None: """Buyer operations must configure the worker without overstating it.""" - content = DEPLOYMENT_PLAYBOOK.read_text(encoding="utf-8") + content = normalized_text(DEPLOYMENT_PLAYBOOK) self.assertIn("artifact-deletion-receipts.log", content) self.assertIn("bounded scheduled recovery", content) From 4529a5d5e3e3d48fa02e3a811c8d4ee6556900d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:45:23 +0900 Subject: [PATCH 151/178] test(docs): verify recovery semantics instead of wording --- scripts/test_durable_cleanup_documentation_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test_durable_cleanup_documentation_contract.py b/scripts/test_durable_cleanup_documentation_contract.py index 1c7161aeb..99cbc32b7 100644 --- a/scripts/test_durable_cleanup_documentation_contract.py +++ b/scripts/test_durable_cleanup_documentation_contract.py @@ -63,7 +63,8 @@ def test_authorization_decision_distinguishes_local_durability_from_distributed_ content = normalized_text(AUTHORIZATION_DECISION) self.assertIn("durable deletion receipt", content) - self.assertIn("bounded scheduled recovery", content) + self.assertIn("scheduled recovery", content) + self.assertIn("bounded fixed-delay schedule", content) self.assertIn("distributed generation fence", content) self.assertNotIn("This slice does not persist a deletion receipt", content) self.assertNotIn("No durable artifact-cleanup evidence exists in this slice", content) From 6e2235facc7d60a4c82310470adf7b3bb44282d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:49:40 +0900 Subject: [PATCH 152/178] test(lifecycle): cover receipt tenant conflict through public API --- ...DeletionCoordinatorBranchCoverageTest.java | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java index 4ab0759db..4a57d3a24 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java @@ -3,62 +3,55 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.time.Instant; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.repository.ConversionJobRepository; /** - * Covers independent tenant and artifact-generation conflict checks for an - * already active deletion lifecycle. + * Covers the fail-closed tenant conflict for an already active deletion + * lifecycle through the public coordinator boundary. */ class ArtifactDeletionCoordinatorBranchCoverageTest { @Test - void existingTenantReceiptRejectsAChangedArtifactChecksum() throws Exception { + void existingReceiptRejectsAJobPresentedAsAnotherTenant() { UUID jobId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); - String originalChecksum = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - String changedChecksum = - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); ledger.request( UUID.fromString("11111111-2222-3333-4444-555555555555"), "tenant-edge", jobId, - originalChecksum, + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, "audit-v1:0123456789abcdef0123456789abcdef", Instant.parse("2026-08-06T12:00:00Z") ); + ConversionJobRepository repository = mock(ConversionJobRepository.class); + when(repository.findByTenantAndId("tenant-other", jobId)) + .thenReturn(Optional.of(mock(ConversionJob.class))); ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( - mock(ConversionJobRepository.class), + repository, mock(ArtifactStore.class), ledger, new ArtifactDeletionMetrics(ledger), 10 ); - Method requestReceipt = ArtifactDeletionCoordinator.class.getDeclaredMethod( - "requestReceipt", - String.class, - UUID.class, - String.class - ); - requestReceipt.setAccessible(true); - InvocationTargetException invocation = assertThrows( - InvocationTargetException.class, - () -> requestReceipt.invoke(coordinator, "tenant-edge", jobId, changedChecksum) + IllegalStateException conflict = assertThrows( + IllegalStateException.class, + () -> coordinator.deleteForTenant(jobId, "tenant-other") ); assertEquals( "artifact deletion receipt conflicts with the active lifecycle", - invocation.getCause().getMessage() + conflict.getMessage() ); } } From afb492e186bacf0def8f119fd891e262d417cd39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:50:33 +0900 Subject: [PATCH 153/178] test(lifecycle): align replay branch coverage with checksum capture --- ...ifactDeletionLedgerBranchCoverageTest.java | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java index 745085478..ce101d9f3 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java @@ -28,7 +28,8 @@ void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { Method validator = privateMethod( "validateReplayTransition", ArtifactDeletionReceipt.class, - ArtifactDeletionReceipt.class + ArtifactDeletionReceipt.class, + boolean.class ); assertInvalidTransition( @@ -41,6 +42,11 @@ void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START.plusSeconds(1), 1, START) ); + assertInvalidTransition( + validator, + receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), + receipt(ArtifactDeletionState.DELETION_REQUESTED, START.plusSeconds(1), 0, null) + ); assertInvalidTransition( validator, receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START, 0, null), @@ -79,27 +85,37 @@ void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { } @Test - void replayTransitionAcceptsRequestedPendingOutcomesAndFailedRetry() throws Exception { + void replayTransitionAcceptsChecksumCaptureAndMonotonicLifecycleOutcomes() throws Exception { Method validator = privateMethod( "validateReplayTransition", ArtifactDeletionReceipt.class, - ArtifactDeletionReceipt.class + ArtifactDeletionReceipt.class, + boolean.class ); validator.invoke( null, receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), - receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START.plusSeconds(1), 0, null) + receipt(ArtifactDeletionState.DELETION_REQUESTED, START.plusSeconds(1), 0, null), + true + ); + validator.invoke( + null, + receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), + receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START.plusSeconds(1), 0, null), + false ); validator.invoke( null, receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START, 0, null), - receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null) + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null), + false ); validator.invoke( null, receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), - receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 0, null) + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 0, null), + false ); validator.invoke( null, @@ -109,12 +125,14 @@ void replayTransitionAcceptsRequestedPendingOutcomesAndFailedRetry() throws Exce START.plusSeconds(1), 1, START.plusSeconds(1) - ) + ), + false ); validator.invoke( null, receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, START, 1, START), - receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 1, START) + receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 1, START), + false ); } @@ -153,7 +171,10 @@ private static void assertInvalidTransition( ArtifactDeletionReceipt current, ArtifactDeletionReceipt replayed ) { - assertThrows(InvocationTargetException.class, () -> validator.invoke(null, current, replayed)); + assertThrows( + InvocationTargetException.class, + () -> validator.invoke(null, current, replayed, false) + ); } private static ArtifactDeletionReceipt receipt( From b772efd14659cf75efc34da7bf38ef116c01a294 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:52:11 +0900 Subject: [PATCH 154/178] test(lifecycle): accept durable checksum capture evidence --- .../ArtifactDeletionLedgerCoverageTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java index 4dd550594..e0e53c363 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java @@ -100,17 +100,20 @@ void immutableIdentityComparisonRejectsEveryChangedField() { } @Test - void receiptConstructorRejectsInconsistentStateEvidence() { + void receiptConstructorAcceptsChecksumCaptureAndRejectsInconsistentStateEvidence() { 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 - )); + assertEquals( + REQUESTED_AT.plusSeconds(1), + receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + ).stateChangedAt() + ); assertThrows(IllegalArgumentException.class, () -> receipt( REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT, REQUESTED_AT, From bddd7538a3c6b5851848dbec11a2dda65103ea85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:58:01 +0900 Subject: [PATCH 155/178] test(lifecycle): cover unavailable digest fail-closed path --- ...DeletionCoordinatorBranchCoverageTest.java | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java index 4a57d3a24..41571fe37 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java @@ -2,10 +2,15 @@ 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 static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.security.Provider; +import java.security.Security; import java.time.Instant; +import java.util.Comparator; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -14,10 +19,11 @@ import com.clearfolio.viewer.artifact.ArtifactStore; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.repository.ConversionJobRepository; +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport; +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.ProviderPosition; /** - * Covers the fail-closed tenant conflict for an already active deletion - * lifecycle through the public coordinator boundary. + * Covers fail-closed coordinator branches through public deletion boundaries. */ class ArtifactDeletionCoordinatorBranchCoverageTest { @@ -54,4 +60,45 @@ void existingReceiptRejectsAJobPresentedAsAnotherTenant() { conflict.getMessage() ); } + + @Test + void deletionFailsBeforeReceiptAcceptanceWhenSha256IsUnavailable() { + synchronized (SecurityProviderTestSupport.SECURITY_PROVIDERS_LOCK) { + List removedProviders = + SecurityProviderTestSupport.sha256ProviderPositions(); + removedProviders.stream() + .map(ProviderPosition::provider) + .map(Provider::getName) + .forEach(Security::removeProvider); + try { + UUID jobId = UUID.fromString("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ConversionJobRepository repository = mock(ConversionJobRepository.class); + when(repository.findByTenantAndId("tenant-edge", jobId)) + .thenReturn(Optional.of(mock(ConversionJob.class))); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + repository, + mock(ArtifactStore.class), + ledger, + new ArtifactDeletionMetrics(ledger), + 10 + ); + + IllegalStateException unavailable = assertThrows( + IllegalStateException.class, + () -> coordinator.deleteForTenant(jobId, "tenant-edge") + ); + + assertEquals("SHA-256 digest unavailable", unavailable.getMessage()); + assertTrue(ledger.findByJobId(jobId).isEmpty()); + } finally { + removedProviders.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(providerPosition -> Security.insertProviderAt( + providerPosition.provider(), + providerPosition.position() + )); + } + } + } } From 9d26f9c5565c3835e9d4e1ca885e20641179fc8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 13:58:50 +0900 Subject: [PATCH 156/178] test(lifecycle): cover checksum state invariants --- ...factDeletionReceiptBranchCoverageTest.java | 103 ++++++++++++++++-- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java index 0b4b5ce00..7a38937c5 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java @@ -9,11 +9,20 @@ import org.junit.jupiter.api.Test; /** - * Covers fail-closed receipt-state combinations at the boundaries of attempt - * and failure evidence. + * Covers fail-closed receipt-state combinations at checksum, attempt, and + * failure-evidence boundaries. */ class ArtifactDeletionReceiptBranchCoverageTest { + 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-edge"; + 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-06T12:00:00Z"); @Test @@ -75,11 +84,11 @@ void completedReceiptRejectsFailureEvidence() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> new ArtifactDeletionReceipt( - UUID.fromString("11111111-2222-3333-4444-555555555555"), - "tenant-edge", - UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "audit-v1:0123456789abcdef0123456789abcdef", + REQUEST_ID, + TENANT_ID, + JOB_ID, + CHECKSUM, + AUDIT_ID, REQUESTED_AT, completedAt, ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, @@ -93,6 +102,56 @@ void completedReceiptRejectsFailureEvidence() { assertEquals("completed receipt fields are inconsistent", exception.getMessage()); } + @Test + void exactChecksumCannotBeCapturedTwice() { + ArtifactDeletionReceipt pending = requestedReceipt( + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + ArtifactDeletionState.DELETION_REQUESTED + ); + ArtifactDeletionReceipt captured = pending.captureArtifactChecksum( + CHECKSUM, + REQUESTED_AT.plusSeconds(1) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> captured.captureArtifactChecksum(CHECKSUM, REQUESTED_AT.plusSeconds(2)) + ); + + assertEquals("invalid artifact deletion transition", exception.getMessage()); + } + + @Test + void metadataCannotBeTombstonedWhileChecksumIsPending() { + ArtifactDeletionReceipt pending = requestedReceipt( + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + ArtifactDeletionState.DELETION_REQUESTED + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> pending.markMetadataTombstoned(REQUESTED_AT.plusSeconds(1)) + ); + + assertEquals("invalid artifact deletion transition", exception.getMessage()); + } + + @Test + void pendingChecksumIsRejectedAfterMetadataTombstoning() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> requestedReceipt( + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + ArtifactDeletionState.METADATA_TOMBSTONED + ) + ); + + assertEquals( + "pending artifact checksum is only valid before metadata tombstoning", + exception.getMessage() + ); + } + private static ArtifactDeletionReceipt failedReceipt( int attemptCount, Instant lastAttemptAt, @@ -100,11 +159,11 @@ private static ArtifactDeletionReceipt failedReceipt( String failureCode ) { return new ArtifactDeletionReceipt( - UUID.fromString("11111111-2222-3333-4444-555555555555"), - "tenant-edge", - UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "audit-v1:0123456789abcdef0123456789abcdef", + REQUEST_ID, + TENANT_ID, + JOB_ID, + CHECKSUM, + AUDIT_ID, REQUESTED_AT, stateChangedAt, ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, @@ -114,4 +173,24 @@ private static ArtifactDeletionReceipt failedReceipt( failureCode ); } + + private static ArtifactDeletionReceipt requestedReceipt( + String checksum, + ArtifactDeletionState state + ) { + return new ArtifactDeletionReceipt( + REQUEST_ID, + TENANT_ID, + JOB_ID, + checksum, + AUDIT_ID, + REQUESTED_AT, + REQUESTED_AT, + state, + 0, + null, + null, + null + ); + } } From 31fc206918b9b4232f24e0e352392f8d30bcfc0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:00:16 +0900 Subject: [PATCH 157/178] test(lifecycle): cover checksum replay and defensive ledger branches --- ...ifactDeletionLedgerBranchCoverageTest.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java index ce101d9f3..703d85c4f 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java @@ -1,17 +1,22 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Instant; import java.util.Base64; +import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; @@ -22,6 +27,8 @@ class ArtifactDeletionLedgerBranchCoverageTest { private static final Instant START = Instant.parse("2026-08-06T12:00:00Z"); + private static final String CHECKSUM = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @Test void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { @@ -136,6 +143,83 @@ void replayTransitionAcceptsChecksumCaptureAndMonotonicLifecycleOutcomes() throw ); } + @Test + void checksumCaptureDetectorCoversEveryConjunct() throws Exception { + Method detector = privateMethod( + "isChecksumCaptureTransition", + ArtifactDeletionReceipt.class, + ArtifactDeletionReceipt.class + ); + + assertFalse(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.METADATA_TOMBSTONED, true, 0, null), + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, false, 0, null), + true + )); + assertFalse(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, true, 0, null), + checksumReceipt(ArtifactDeletionState.METADATA_TOMBSTONED, false, 0, null), + true + )); + assertFalse(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, false, 0, null), + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, false, 0, null), + true + )); + assertFalse(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, true, 0, null), + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, true, 0, null), + true + )); + assertFalse(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, true, 0, null), + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, false, 0, null), + false + )); + assertFalse(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, true, 0, null), + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, false, 1, START), + true + )); + assertTrue(detectChecksumCapture( + detector, + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, true, 0, null), + checksumReceipt(ArtifactDeletionState.DELETION_REQUESTED, false, 0, null), + true + )); + } + + @Test + void recordChecksumRejectsMissingReceiptAndChangedRequestIdentity() throws Exception { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + UUID missingJobId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + IllegalStateException missing = assertThrows( + IllegalStateException.class, + () -> ledger.recordArtifactChecksum(missingJobId, CHECKSUM, START) + ); + assertEquals("artifact deletion receipt not found", missing.getMessage()); + + UUID conflictingJobId = UUID.fromString("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"); + ArtifactDeletionReceipt current = mock(ArtifactDeletionReceipt.class); + ArtifactDeletionReceipt changedIdentity = mock(ArtifactDeletionReceipt.class); + when(current.captureArtifactChecksum(CHECKSUM, START)).thenReturn(changedIdentity); + when(current.hasSameRequestIdentity(changedIdentity)).thenReturn(false); + receiptMap(ledger).put(conflictingJobId, current); + + IllegalStateException conflict = assertThrows( + IllegalStateException.class, + () -> ledger.recordArtifactChecksum(conflictingJobId, CHECKSUM, START) + ); + assertEquals("artifact deletion receipt conflicts with the active lifecycle", conflict.getMessage()); + } + @Test void privateCodecHelpersCoverNullBlankAndPresentValues() throws Exception { Method decodeRequired = privateMethod("decodeRequired", String.class); @@ -177,6 +261,27 @@ private static void assertInvalidTransition( ); } + private static boolean detectChecksumCapture( + Method detector, + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed, + boolean sameIdentity + ) throws ReflectiveOperationException { + when(current.hasSameRequestIdentity(replayed)).thenReturn(sameIdentity); + return (boolean) detector.invoke(null, current, replayed); + } + + private static ArtifactDeletionReceipt checksumReceipt( + ArtifactDeletionState state, + boolean pending, + int attempts, + Instant lastAttemptAt + ) { + ArtifactDeletionReceipt receipt = receipt(state, START, attempts, lastAttemptAt); + when(receipt.isArtifactChecksumPending()).thenReturn(pending); + return receipt; + } + private static ArtifactDeletionReceipt receipt( ArtifactDeletionState state, Instant stateChangedAt, @@ -191,6 +296,14 @@ private static ArtifactDeletionReceipt receipt( return receipt; } + @SuppressWarnings("unchecked") + private static Map receiptMap(ArtifactDeletionLedger ledger) + throws ReflectiveOperationException { + Field field = ArtifactDeletionLedger.class.getDeclaredField("receiptsByJobId"); + field.setAccessible(true); + return (Map) field.get(ledger); + } + private static String encode(String value) { return Base64.getUrlEncoder() .withoutPadding() From 98e63bc7b9253d137f02cadfee926987831db9c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:05:09 +0900 Subject: [PATCH 158/178] test(lifecycle): cover digest provider loss after receipt acceptance --- ...DeletionCoordinatorBranchCoverageTest.java | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java index 41571fe37..77e735b90 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorBranchCoverageTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.security.Provider; @@ -92,13 +94,60 @@ void deletionFailsBeforeReceiptAcceptanceWhenSha256IsUnavailable() { assertEquals("SHA-256 digest unavailable", unavailable.getMessage()); assertTrue(ledger.findByJobId(jobId).isEmpty()); } finally { + restoreProviders(removedProviders); + } + } + } + + @Test + void digestProviderLossAfterReceiptAcceptancePreservesMetadataAndPendingEvidence() { + synchronized (SecurityProviderTestSupport.SECURITY_PROVIDERS_LOCK) { + List removedProviders = + SecurityProviderTestSupport.sha256ProviderPositions(); + UUID jobId = UUID.fromString("cccccccc-dddd-eeee-ffff-000000000000"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ConversionJobRepository repository = mock(ConversionJobRepository.class); + when(repository.findByTenantAndId("tenant-edge", jobId)) + .thenReturn(Optional.of(mock(ConversionJob.class))); + ArtifactStore artifactStore = mock(ArtifactStore.class); + when(artifactStore.getPdf(jobId)).thenAnswer(ignored -> { removedProviders.stream() - .sorted(Comparator.comparingInt(ProviderPosition::position)) - .forEach(providerPosition -> Security.insertProviderAt( - providerPosition.provider(), - providerPosition.position() - )); + .map(ProviderPosition::provider) + .map(Provider::getName) + .forEach(Security::removeProvider); + return Optional.of(new byte[]{0x25, 0x50, 0x44, 0x46}); + }); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + repository, + artifactStore, + ledger, + new ArtifactDeletionMetrics(ledger), + 10 + ); + + try { + IllegalStateException unavailable = assertThrows( + IllegalStateException.class, + () -> coordinator.deleteForTenant(jobId, "tenant-edge") + ); + + assertEquals("SHA-256 digest unavailable", unavailable.getMessage()); + ArtifactDeletionReceipt receipt = ledger.findByJobId(jobId).orElseThrow(); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, receipt.state()); + assertTrue(receipt.isArtifactChecksumPending()); + verify(repository, never()).deleteByTenantAndId("tenant-edge", jobId); + } finally { + restoreProviders(removedProviders); } } } + + private static void restoreProviders(List removedProviders) { + removedProviders.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(providerPosition -> Security.insertProviderAt( + providerPosition.provider(), + providerPosition.position() + )); + } } From e510d3c1de4468c6a28dd257c53305928fa2dd28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:06:02 +0900 Subject: [PATCH 159/178] test(lifecycle): align ledger conflict evidence with production contract --- .../lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java index 703d85c4f..74f8d2714 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java @@ -217,7 +217,7 @@ void recordChecksumRejectsMissingReceiptAndChangedRequestIdentity() throws Excep IllegalStateException.class, () -> ledger.recordArtifactChecksum(conflictingJobId, CHECKSUM, START) ); - assertEquals("artifact deletion receipt conflicts with the active lifecycle", conflict.getMessage()); + assertEquals("artifact deletion receipt conflicts with an existing lifecycle", conflict.getMessage()); } @Test From 68f2babaa6a460329491d20a384d9f94a94472ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:06:57 +0900 Subject: [PATCH 160/178] test(lifecycle): align transition evidence with stable contract --- .../lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java index 7a38937c5..8a41fb551 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptBranchCoverageTest.java @@ -118,7 +118,7 @@ void exactChecksumCannotBeCapturedTwice() { () -> captured.captureArtifactChecksum(CHECKSUM, REQUESTED_AT.plusSeconds(2)) ); - assertEquals("invalid artifact deletion transition", exception.getMessage()); + assertEquals("artifact deletion receipt transition is invalid", exception.getMessage()); } @Test @@ -133,7 +133,7 @@ void metadataCannotBeTombstonedWhileChecksumIsPending() { () -> pending.markMetadataTombstoned(REQUESTED_AT.plusSeconds(1)) ); - assertEquals("invalid artifact deletion transition", exception.getMessage()); + assertEquals("artifact deletion receipt transition is invalid", exception.getMessage()); } @Test From 819ddf28e7ca812a0270964449966f7fc290b015 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:00:59 +0900 Subject: [PATCH 161/178] test(lifecycle): prevent bounded recovery starvation --- .../ArtifactDeletionRecoveryFairnessTest.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java new file mode 100644 index 000000000..122f94e35 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java @@ -0,0 +1,95 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Proves that one permanently failing oldest receipt cannot starve newer + * deletion work behind a bounded recovery batch. + */ +class ArtifactDeletionRecoveryFairnessTest { + + private static final UUID BLOCKED_JOB_ID = + UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID READY_JOB_ID = + UUID.fromString("00000000-0000-0000-0000-000000000002"); + + @Test + void failedOldestSnapshotIsDeferredSoNewerCleanupRunsOnTheNextPass() { + Instant requestedAt = Instant.parse("2026-08-07T00:00:00Z"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ledger.request( + UUID.fromString("10000000-0000-0000-0000-000000000001"), + "tenant-north", + BLOCKED_JOB_ID, + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + "cleanup-v1:blocked", + requestedAt + ); + ledger.request( + UUID.fromString("10000000-0000-0000-0000-000000000002"), + "tenant-north", + READY_JOB_ID, + ArtifactDeletionCoordinator.ABSENT_ARTIFACT_CHECKSUM, + "cleanup-v1:ready", + requestedAt.plusSeconds(1) + ); + ledger.markMetadataTombstoned(READY_JOB_ID, requestedAt.plusSeconds(1)); + ledger.markCleanupPending(READY_JOB_ID, requestedAt.plusSeconds(1)); + + ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(ledger); + ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( + org.mockito.Mockito.mock(ConversionJobRepository.class), + new BlockingOldestArtifactStore(), + ledger, + metrics, + new ArtifactLifecycleLockRegistry(8), + 1 + ); + + assertEquals(1, coordinator.retryPendingWork()); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, + ledger.findByJobId(BLOCKED_JOB_ID).orElseThrow().state()); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + ledger.findByJobId(READY_JOB_ID).orElseThrow().state()); + + assertEquals(1, coordinator.retryPendingWork()); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + ledger.findByJobId(READY_JOB_ID).orElseThrow().state()); + assertEquals(1L, metrics.failedAttempts()); + assertEquals(1L, metrics.completedAttempts()); + } + + /** + * Simulates a permanently unavailable first artifact snapshot while every + * other document reports a confirmed-absent artifact. + */ + private static final class BlockingOldestArtifactStore implements ArtifactStore { + + @Override + public void putPdf(UUID docId, byte[] pdfBytes) { + throw new UnsupportedOperationException("test store does not accept writes"); + } + + @Override + public Optional getPdf(UUID docId) { + if (BLOCKED_JOB_ID.equals(docId)) { + throw new IllegalStateException("controlled snapshot failure"); + } + return Optional.empty(); + } + + @Override + public void deletePdf(UUID docId) { + // Confirmed-absent artifacts need no physical deletion in this fixture. + } + } +} From 245957e69631c4c2dce6306dd72161214a6b2a79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:09:17 +0900 Subject: [PATCH 162/178] fix(lifecycle): rotate bounded deletion recovery fairly --- .../ArtifactDeletionCoordinator.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java index bf4aae4e4..0c42cc186 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java @@ -55,6 +55,7 @@ public class ArtifactDeletionCoordinator { private final ArtifactDeletionMetrics metrics; private final ArtifactLifecycleLockRegistry lifecycleLocks; private final int maxReceiptsPerRun; + private int recoveryCursor; /** * Creates the Spring-managed coordinator and validates the recovery bound. @@ -156,16 +157,26 @@ public void deleteGlobally(UUID jobId) { * Replays one bounded deterministic batch of incomplete deletion receipts. * *

A failure in one receipt is isolated so later receipts in the same - * batch remain eligible. The receipt remains authoritative recovery evidence.

+ * batch remain eligible. Selection rotates between passes, preventing one + * permanently failing early receipt from starving newer work when the + * configured batch is smaller than the pending backlog. The durable receipt + * remains authoritative; a process restart safely begins again from the + * deterministic request order.

* * @return number of receipts selected for this recovery pass */ - public int retryPendingWork() { + public synchronized int retryPendingWork() { List pending = receiptStore.pendingReceipts(); int selected = Math.min(maxReceiptsPerRun, pending.size()); - for (int index = 0; index < selected; index++) { + if (selected == 0) { + recoveryCursor = 0; + return 0; + } + int start = Math.floorMod(recoveryCursor, pending.size()); + for (int offset = 0; offset < selected; offset++) { + int candidateIndex = (int) ((start + (long) offset) % pending.size()); try { - resumeReceipt(pending.get(index)); + resumeReceipt(pending.get(candidateIndex)); } catch (RuntimeException exception) { metrics.recordFailed(); log.warn( @@ -174,6 +185,7 @@ public int retryPendingWork() { ); } } + recoveryCursor = (int) ((start + (long) selected) % pending.size()); return selected; } From 80aec071353aa31217e745c48963829ef51df6a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:13:45 +0900 Subject: [PATCH 163/178] docs(changelog): record fair bounded deletion recovery --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4d202b1..4b81e96ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. - CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다. - Spring scheduling을 활성화하고 artifact deletion recovery를 30초 fixed delay, 실행당 최대 100개 receipt로 제한했습니다. Cleanup 완료·실패 누적값과 pending receipt 수는 dependency-free aggregate evidence로 제공하며 tenant/job/checksum/예외/경로 차원을 저장하지 않습니다. +- Bounded artifact-deletion recovery는 결정적 pending 순서를 실행마다 회전해 영구적으로 실패하는 가장 오래된 receipt 하나가 제한된 batch 뒤의 최신 cleanup을 기아 상태로 만들지 않도록 했습니다. Receipt와 상태 전이는 계속 durable source of truth로 유지하며, process restart는 안전하게 결정적 요청 순서에서 다시 시작합니다. - Durable artifact deletion은 프로세스 전체 모니터 대신 공유 per-job lifecycle lock만 사용해 서로 다른 문서의 삭제와 복구를 병렬화합니다. WebFlux 관리자 DELETE는 blocking artifact I/O를 Reactor bounded-elastic worker로 격리합니다. ### Security @@ -88,4 +89,4 @@ - `jackson-databind`도 `2.22.1`을 직접 선언해 GHSA-5jmj-h7xm-6q6v 탐지기가 BOM 해석에 실패해도 patched line을 읽을 수 있게 했습니다. OSV Scanner v2.3.8이 advisory 본문상 patched인 `2.22.1`을 계속 매칭하므로 루트 `osv-scanner.toml`에는 이 GHSA만 2026-08-15까지 좁게 예외 처리했습니다. - 과거 QA 증거 SBOM의 Jackson purl/ref도 `2.21.5`로 갱신해 Scorecard/OSV가 저장소 내 stale SBOM을 취약 의존성으로 재탐지하지 않게 했습니다. - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. -- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. +- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. \ No newline at end of file From 936d7b0db32639728a1c4780124dee1966f28812 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:17:52 +0900 Subject: [PATCH 164/178] docs(security): define bounded recovery fairness --- docs/security/2026-08-05-administrative-authorization.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index 6a9281f67..d829fecc3 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -54,6 +54,8 @@ The lifecycle is receipt first: 5. Mark cleanup pending, compare the exact generation, delete the artifact idempotently, and record completion or a controlled retryable failure. 6. Replay incomplete receipts at application startup and on a bounded fixed-delay schedule. +Within one running process, bounded recovery rotates its starting position through the deterministic pending-receipt order. A permanently failing oldest receipt therefore remains durable and retryable without starving newer work behind a smaller batch limit. The fairness cursor is process-local coordination state, not receipt evidence: after restart the coordinator safely begins again from deterministic request order. A high-churn multi-instance adapter that requires fairness across process loss must persist an equivalent lease, cursor, or next-attempt ordering without weakening receipt identity, generation fencing, or idempotency. + `LifecycleFencedArtifactStore` shares the per-job lifecycle lock and rejects publication after a deletion receipt exists. An in-flight publication that completes before receipt acceptance becomes the captured generation and is deleted; a later publication fails closed. Aggregate metrics expose only completed, failed, pending, and bounded execution evidence. They do not use tenant, job, digest, path, filename, exception message, or other high-cardinality dimensions. The append-only file adapter forces each accepted receipt snapshot before reporting it durable. It supports restart replay and preserves incomplete cleanup evidence. The in-memory adapter remains available for standalone tests and ephemeral use. @@ -112,6 +114,7 @@ Automated tests must exercise the real signed-claim verifier and prove: - first-read failure preserves metadata and restart-safe pending evidence rather than claiming confirmed absence; - exact digest binding is one-way, durable, replay-validated, and required before metadata tombstone; - successful cleanup, read failure, digest mismatch, delete failure, retry, restart, and bounded-batch recovery are deterministic; +- a permanently failing oldest receipt cannot starve newer cleanup across repeated bounded passes in one process; - write-after-receipt and in-flight publication races cannot recreate deleted confidential bytes; - cleanup evidence contains no raw UUID, tenant, path, digest dimension, exception text, or attached throwable; - both signing keys are sourced from the shared config-tree mount; From fbf2cba816676b8d4f02c97c20865ae353a3bacb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:19:14 +0900 Subject: [PATCH 165/178] docs(changelog): restore trailing newline after fairness entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b81e96ff..5fa2f9e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,4 +89,4 @@ - `jackson-databind`도 `2.22.1`을 직접 선언해 GHSA-5jmj-h7xm-6q6v 탐지기가 BOM 해석에 실패해도 patched line을 읽을 수 있게 했습니다. OSV Scanner v2.3.8이 advisory 본문상 patched인 `2.22.1`을 계속 매칭하므로 루트 `osv-scanner.toml`에는 이 GHSA만 2026-08-15까지 좁게 예외 처리했습니다. - 과거 QA 증거 SBOM의 Jackson purl/ref도 `2.21.5`로 갱신해 Scorecard/OSV가 저장소 내 stale SBOM을 취약 의존성으로 재탐지하지 않게 했습니다. - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. -- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. \ No newline at end of file +- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. From 39ee6921c5810ad7725eaa3bec7b87562961ca93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:20:03 +0900 Subject: [PATCH 166/178] test(lifecycle): require restart-durable recovery fairness --- .../ArtifactDeletionRecoveryFairnessTest.java | 78 +++++++++++++------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java index 122f94e35..e024bf4b7 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionRecoveryFairnessTest.java @@ -1,19 +1,22 @@ package com.clearfolio.viewer.lifecycle; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.nio.file.Path; import java.time.Instant; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.clearfolio.viewer.artifact.ArtifactStore; import com.clearfolio.viewer.repository.ConversionJobRepository; /** * Proves that one permanently failing oldest receipt cannot starve newer - * deletion work behind a bounded recovery batch. + * deletion work behind a bounded recovery batch, including across restart. */ class ArtifactDeletionRecoveryFairnessTest { @@ -22,11 +25,15 @@ class ArtifactDeletionRecoveryFairnessTest { private static final UUID READY_JOB_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + @TempDir + Path tempDirectory; + @Test - void failedOldestSnapshotIsDeferredSoNewerCleanupRunsOnTheNextPass() { + void failedOldestSnapshotIsDeferredDurablySoNewerCleanupRunsAfterRestart() { Instant requestedAt = Instant.parse("2026-08-07T00:00:00Z"); - ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); - ledger.request( + Path ledgerPath = tempDirectory.resolve("artifact-deletion-receipts.log"); + ArtifactDeletionLedger firstLedger = new ArtifactDeletionLedger(ledgerPath); + firstLedger.request( UUID.fromString("10000000-0000-0000-0000-000000000001"), "tenant-north", BLOCKED_JOB_ID, @@ -34,7 +41,7 @@ void failedOldestSnapshotIsDeferredSoNewerCleanupRunsOnTheNextPass() { "cleanup-v1:blocked", requestedAt ); - ledger.request( + firstLedger.request( UUID.fromString("10000000-0000-0000-0000-000000000002"), "tenant-north", READY_JOB_ID, @@ -42,30 +49,55 @@ void failedOldestSnapshotIsDeferredSoNewerCleanupRunsOnTheNextPass() { "cleanup-v1:ready", requestedAt.plusSeconds(1) ); - ledger.markMetadataTombstoned(READY_JOB_ID, requestedAt.plusSeconds(1)); - ledger.markCleanupPending(READY_JOB_ID, requestedAt.plusSeconds(1)); + firstLedger.markMetadataTombstoned(READY_JOB_ID, requestedAt.plusSeconds(1)); + firstLedger.markCleanupPending(READY_JOB_ID, requestedAt.plusSeconds(1)); - ArtifactDeletionMetrics metrics = new ArtifactDeletionMetrics(ledger); - ArtifactDeletionCoordinator coordinator = new ArtifactDeletionCoordinator( - org.mockito.Mockito.mock(ConversionJobRepository.class), - new BlockingOldestArtifactStore(), - ledger, - metrics, - new ArtifactLifecycleLockRegistry(8), - 1 + ArtifactStore artifactStore = new BlockingOldestArtifactStore(); + ArtifactDeletionMetrics firstMetrics = new ArtifactDeletionMetrics(firstLedger); + ArtifactDeletionCoordinator firstProcess = coordinator( + artifactStore, + firstLedger, + firstMetrics ); - assertEquals(1, coordinator.retryPendingWork()); - assertEquals(ArtifactDeletionState.DELETION_REQUESTED, - ledger.findByJobId(BLOCKED_JOB_ID).orElseThrow().state()); + assertEquals(1, firstProcess.retryPendingWork()); + ArtifactDeletionReceipt deferred = firstLedger.findByJobId(BLOCKED_JOB_ID).orElseThrow(); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, deferred.state()); + assertEquals(1, deferred.attemptCount()); + assertNotNull(deferred.lastAttemptAt()); + assertEquals("artifact_store_read_failed", deferred.failureCode()); assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, - ledger.findByJobId(READY_JOB_ID).orElseThrow().state()); + firstLedger.findByJobId(READY_JOB_ID).orElseThrow().state()); + assertEquals(1L, firstMetrics.failedAttempts()); + + ArtifactDeletionLedger restartedLedger = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionMetrics restartedMetrics = new ArtifactDeletionMetrics(restartedLedger); + ArtifactDeletionCoordinator restartedProcess = coordinator( + artifactStore, + restartedLedger, + restartedMetrics + ); - assertEquals(1, coordinator.retryPendingWork()); + assertEquals(1, restartedProcess.retryPendingWork()); assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, - ledger.findByJobId(READY_JOB_ID).orElseThrow().state()); - assertEquals(1L, metrics.failedAttempts()); - assertEquals(1L, metrics.completedAttempts()); + restartedLedger.findByJobId(READY_JOB_ID).orElseThrow().state()); + assertEquals(0L, restartedMetrics.failedAttempts()); + assertEquals(1L, restartedMetrics.completedAttempts()); + } + + private static ArtifactDeletionCoordinator coordinator( + ArtifactStore artifactStore, + ArtifactDeletionReceiptStore receiptStore, + ArtifactDeletionMetrics metrics + ) { + return new ArtifactDeletionCoordinator( + org.mockito.Mockito.mock(ConversionJobRepository.class), + artifactStore, + receiptStore, + metrics, + new ArtifactLifecycleLockRegistry(8), + 1 + ); } /** From 7a6189588e852406a242ad12a3f18b9bd23788ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:28:26 +0900 Subject: [PATCH 167/178] fix(lifecycle): persist snapshot retry evidence --- .../lifecycle/ArtifactDeletionReceipt.java | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java index d403d3150..7ccf394aa 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java @@ -23,10 +23,10 @@ * @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 attemptCount number of failed lifecycle attempts + * @param lastAttemptAt instant of the latest failed lifecycle attempt, when any * @param completedAt instant when cleanup became terminally complete, when any - * @param failureCode controlled non-sensitive failure code, when cleanup failed + * @param failureCode controlled non-sensitive failure code for the current failed attempt, when any */ public record ArtifactDeletionReceipt( UUID requestId, @@ -49,6 +49,11 @@ public record ArtifactDeletionReceipt( */ static final String PENDING_ARTIFACT_CHECKSUM = "pending"; + /** + * Controlled failure code for an unavailable pre-tombstone artifact snapshot. + */ + static final String SNAPSHOT_READ_FAILURE_CODE = "artifact_store_read_failed"; + 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}"); @@ -74,6 +79,7 @@ public record ArtifactDeletionReceipt( } failureCode = normalizeOptional(failureCode); validateStateFields( + artifactChecksum, requestedAt, stateChangedAt, state, @@ -121,6 +127,22 @@ boolean isArtifactChecksumPending() { return PENDING_ARTIFACT_CHECKSUM.equals(artifactChecksum); } + ArtifactDeletionReceipt recordArtifactSnapshotFailure(Instant attemptedAt) { + requireState(ArtifactDeletionState.DELETION_REQUESTED); + if (!isArtifactChecksumPending()) { + throw invalidTransition(); + } + Instant requiredAttemptedAt = requireForwardTime(attemptedAt); + return snapshot( + ArtifactDeletionState.DELETION_REQUESTED, + requiredAttemptedAt, + Math.addExact(attemptCount, 1), + requiredAttemptedAt, + null, + SNAPSHOT_READ_FAILURE_CODE + ); + } + ArtifactDeletionReceipt captureArtifactChecksum(String checksum, Instant capturedAt) { requireState(ArtifactDeletionState.DELETION_REQUESTED); if (!isArtifactChecksumPending()) { @@ -238,6 +260,7 @@ private void requireState(ArtifactDeletionState requiredState) { } private static void validateStateFields( + String currentArtifactChecksum, Instant currentRequestedAt, Instant currentStateChangedAt, ArtifactDeletionState currentState, @@ -254,13 +277,7 @@ private static void validateStateFields( } boolean hasAttempts = currentAttemptCount > 0; if (hasAttempts != (currentLastAttemptAt != null)) { - throw new IllegalArgumentException("cleanup attempt evidence is inconsistent"); - } - if (currentState == ArtifactDeletionState.DELETION_REQUESTED && hasAttempts) { - throw new IllegalArgumentException("requested receipt fields are inconsistent"); - } - if (currentState == ArtifactDeletionState.METADATA_TOMBSTONED && hasAttempts) { - throw new IllegalArgumentException("tombstoned receipt fields are inconsistent"); + throw new IllegalArgumentException("lifecycle attempt evidence is inconsistent"); } if (currentState == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED) { if (currentCompletedAt == null @@ -273,6 +290,18 @@ private static void validateStateFields( if (currentCompletedAt != null) { throw new IllegalArgumentException("only completed receipts may have completedAt"); } + if (currentState == ArtifactDeletionState.DELETION_REQUESTED + && PENDING_ARTIFACT_CHECKSUM.equals(currentArtifactChecksum)) { + if (hasAttempts) { + if (!currentLastAttemptAt.equals(currentStateChangedAt) + || !SNAPSHOT_READ_FAILURE_CODE.equals(currentFailureCode)) { + throw new IllegalArgumentException("requested receipt fields are inconsistent"); + } + } else if (currentFailureCode != null) { + throw new IllegalArgumentException("requested receipt fields are inconsistent"); + } + return; + } if (currentState == ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED) { if (!hasAttempts || !currentLastAttemptAt.equals(currentStateChangedAt) From 64bec20cff373f74cfc0fc734438f7c1b81d0b77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:28:49 +0900 Subject: [PATCH 168/178] feat(lifecycle): persist pre-snapshot retry evidence --- .../ArtifactDeletionReceiptStore.java | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java index 1347d7101..ff8d8cf8a 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java @@ -56,6 +56,26 @@ ArtifactDeletionReceipt recordArtifactChecksum( Instant capturedAt ); + /** + * Records one failed attempt to snapshot artifact bytes before metadata is + * tombstoned. + * + *

The receipt remains in {@code DELETION_REQUESTED} with the controlled + * pending checksum marker, but its durable attempt time and failure code move + * it behind older eligible work. This prevents a permanently unavailable + * artifact from starving later receipts even after process restart.

+ * + * @param jobId permanently reserved conversion-job identifier + * @param failureCode controlled non-sensitive failure code + * @param attemptedAt failed snapshot instant + * @return updated retryable receipt + */ + ArtifactDeletionReceipt recordSnapshotFailure( + UUID jobId, + String failureCode, + Instant attemptedAt + ); + /** * Records that tenant-owned job metadata has been tombstoned. * @@ -106,9 +126,9 @@ ArtifactDeletionReceipt recordCleanupFailure( Optional findByJobId(UUID jobId); /** - * Returns all incomplete receipts in deterministic request order. + * Returns all incomplete receipts in deterministic next-eligible order. * - * @return pending and failed receipts + * @return pending and failed receipts, with recently failed work deferred */ List pendingReceipts(); From 12a6e21d7d1b09a50bc66b4cd1e7274117f66700 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:36:54 +0900 Subject: [PATCH 169/178] feat(lifecycle): replay durable snapshot failures fairly --- .../lifecycle/ArtifactDeletionLedger.java | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java index b4b24b7d8..6a841e886 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java @@ -35,8 +35,9 @@ *

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 request identity, - * exact artifact-digest capture, timestamps, and monotonic transitions before - * exposing pending work. An empty path selects the standalone in-memory adapter.

+ * exact artifact-digest capture, timestamps, retry evidence, and monotonic + * transitions before exposing pending work. An empty path selects the standalone + * in-memory adapter.

*/ @Repository public class ArtifactDeletionLedger implements ArtifactDeletionReceiptStore { @@ -49,7 +50,8 @@ public class ArtifactDeletionLedger implements ArtifactDeletionReceiptStore { 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) + .comparing(ArtifactDeletionReceipt::stateChangedAt) + .thenComparing(ArtifactDeletionReceipt::requestedAt) .thenComparing(receipt -> receipt.jobId().toString()); private final ConcurrentMap receiptsByJobId = new ConcurrentHashMap<>(); @@ -136,6 +138,19 @@ public synchronized ArtifactDeletionReceipt recordArtifactChecksum( return updated; } + /** {@inheritDoc} */ + @Override + public ArtifactDeletionReceipt recordSnapshotFailure( + UUID jobId, + String failureCode, + Instant attemptedAt + ) { + if (!ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE.equals(failureCode)) { + throw new IllegalArgumentException("failureCode must identify an artifact snapshot read failure"); + } + return transition(jobId, current -> current.recordArtifactSnapshotFailure(attemptedAt)); + } + /** {@inheritDoc} */ @Override public ArtifactDeletionReceipt markMetadataTombstoned(UUID jobId, Instant transitionedAt) { @@ -258,17 +273,18 @@ private void replayLine(String line) { ArtifactDeletionReceipt replayed = parse(line); ArtifactDeletionReceipt current = receiptsByJobId.get(replayed.jobId()); if (current == null) { - if (replayed.state() != ArtifactDeletionState.DELETION_REQUESTED) { + if (!isInitialRequest(replayed)) { throw invalidLine(); } receiptsByJobId.put(replayed.jobId(), replayed); return; } boolean checksumCapture = isChecksumCaptureTransition(current, replayed); + boolean snapshotFailure = isSnapshotFailureTransition(current, replayed); if (!current.hasSameIdentity(replayed) && !checksumCapture) { throw invalidLine(); } - validateReplayTransition(current, replayed, checksumCapture); + validateReplayTransition(current, replayed, checksumCapture, snapshotFailure); receiptsByJobId.put(replayed.jobId(), replayed); } @@ -342,6 +358,15 @@ private static String serialize(ArtifactDeletionReceipt receipt) { ); } + private static boolean isInitialRequest(ArtifactDeletionReceipt receipt) { + return receipt.state() == ArtifactDeletionState.DELETION_REQUESTED + && receipt.attemptCount() == 0 + && receipt.lastAttemptAt() == null + && receipt.completedAt() == null + && receipt.failureCode() == null + && receipt.stateChangedAt().equals(receipt.requestedAt()); + } + private static boolean isChecksumCaptureTransition( ArtifactDeletionReceipt current, ArtifactDeletionReceipt replayed @@ -354,16 +379,32 @@ private static boolean isChecksumCaptureTransition( && hasSameAttemptEvidence(current, replayed); } + private static boolean isSnapshotFailureTransition( + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed + ) { + return current.state() == ArtifactDeletionState.DELETION_REQUESTED + && replayed.state() == ArtifactDeletionState.DELETION_REQUESTED + && current.isArtifactChecksumPending() + && replayed.isArtifactChecksumPending() + && current.hasSameRequestIdentity(replayed) + && replayed.attemptCount() == current.attemptCount() + 1 + && replayed.lastAttemptAt() != null + && replayed.lastAttemptAt().equals(replayed.stateChangedAt()) + && ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE.equals(replayed.failureCode()); + } + private static void validateReplayTransition( ArtifactDeletionReceipt current, ArtifactDeletionReceipt replayed, - boolean checksumCapture + boolean checksumCapture, + boolean snapshotFailure ) { if (replayed.stateChangedAt().isBefore(current.stateChangedAt())) { throw invalidLine(); } boolean valid = switch (current.state()) { - case DELETION_REQUESTED -> checksumCapture || ( + case DELETION_REQUESTED -> snapshotFailure || checksumCapture || ( replayed.state() == ArtifactDeletionState.METADATA_TOMBSTONED && hasSameAttemptEvidence(current, replayed) ); From 9f8c51ebcf9f8cbc798fcd3c01915a8e4181f55d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:40:08 +0900 Subject: [PATCH 170/178] feat(lifecycle): make bounded recovery fair across restart --- .../ArtifactDeletionCoordinator.java | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java index 0c42cc186..3dae44104 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java @@ -28,8 +28,9 @@ *

Each document lifecycle is serialized by a fixed-memory per-job lock shared * with conversion. An authorized request is persisted before the first artifact * read. If that read is temporarily unavailable, metadata remains intact and the - * pending receipt is recoverable after restart; metadata tombstoning begins only - * after the exact artifact digest (or explicit absence digest) is durable.

+ * failed snapshot attempt is durably recorded so later work remains eligible + * after restart. Metadata tombstoning begins only after the exact artifact digest + * (or explicit absence digest) is durable.

* *

Different document identifiers may progress concurrently, so a slow * artifact store cannot serialize every deletion and recovery task in the @@ -55,7 +56,6 @@ public class ArtifactDeletionCoordinator { private final ArtifactDeletionMetrics metrics; private final ArtifactLifecycleLockRegistry lifecycleLocks; private final int maxReceiptsPerRun; - private int recoveryCursor; /** * Creates the Spring-managed coordinator and validates the recovery bound. @@ -157,26 +157,19 @@ public void deleteGlobally(UUID jobId) { * Replays one bounded deterministic batch of incomplete deletion receipts. * *

A failure in one receipt is isolated so later receipts in the same - * batch remain eligible. Selection rotates between passes, preventing one - * permanently failing early receipt from starving newer work when the - * configured batch is smaller than the pending backlog. The durable receipt - * remains authoritative; a process restart safely begins again from the - * deterministic request order.

+ * batch remain eligible. The receipt store orders incomplete work by its + * durable last transition or failed-attempt time, so a failed item moves + * behind older eligible work without relying on process-local cursor state. + * The same order is reconstructed after restart.

* * @return number of receipts selected for this recovery pass */ public synchronized int retryPendingWork() { List pending = receiptStore.pendingReceipts(); int selected = Math.min(maxReceiptsPerRun, pending.size()); - if (selected == 0) { - recoveryCursor = 0; - return 0; - } - int start = Math.floorMod(recoveryCursor, pending.size()); - for (int offset = 0; offset < selected; offset++) { - int candidateIndex = (int) ((start + (long) offset) % pending.size()); + for (int index = 0; index < selected; index++) { try { - resumeReceipt(pending.get(candidateIndex)); + resumeReceipt(pending.get(index)); } catch (RuntimeException exception) { metrics.recordFailed(); log.warn( @@ -185,7 +178,6 @@ public synchronized int retryPendingWork() { ); } } - recoveryCursor = (int) ((start + (long) selected) % pending.size()); return selected; } @@ -292,6 +284,7 @@ private Optional trySnapshotChecksum(UUID jobId) { "artifactStore.getPdf" ); } catch (RuntimeException exception) { + receiptStore.recordSnapshotFailure(jobId, FAILURE_READ, Instant.now()); metrics.recordFailed(); log.warn( "Artifact deletion retained a pre-snapshot receipt. cause={}", From 3f6ffb069423e4292433bd4709b8fa9dc5d27bbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:50:57 +0900 Subject: [PATCH 171/178] test(lifecycle): align replay evidence with snapshot retries --- .../ArtifactDeletionLedgerReplayValidationTest.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java index a310f9156..878b8f22b 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java @@ -157,9 +157,15 @@ void illegalSubsequentStateFailsClosed() throws IOException { void stateSnapshotsRejectImpossibleAttemptEvidence() { assertThrows( IllegalArgumentException.class, - () -> receipt( - ArtifactDeletionState.DELETION_REQUESTED, + () -> new ArtifactDeletionReceipt( + REQUEST_ID, + TENANT_ID, + JOB_ID, + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 1, REQUESTED_AT, null, @@ -172,7 +178,7 @@ void stateSnapshotsRejectImpossibleAttemptEvidence() { ArtifactDeletionState.METADATA_TOMBSTONED, REQUESTED_AT.plusSeconds(1), 1, - REQUESTED_AT, + null, null, null ) From 2df4b45813ff3314fcf280d77034b9302fda6a68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:54:41 +0900 Subject: [PATCH 172/178] test(lifecycle): cover durable snapshot-failure replay --- ...ifactDeletionLedgerBranchCoverageTest.java | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java index 74f8d2714..fd3d44054 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerBranchCoverageTest.java @@ -32,12 +32,7 @@ class ArtifactDeletionLedgerBranchCoverageTest { @Test void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { - Method validator = privateMethod( - "validateReplayTransition", - ArtifactDeletionReceipt.class, - ArtifactDeletionReceipt.class, - boolean.class - ); + Method validator = replayTransitionValidator(); assertInvalidTransition( validator, @@ -92,36 +87,42 @@ void replayTransitionRejectsEveryShortCircuitMismatch() throws Exception { } @Test - void replayTransitionAcceptsChecksumCaptureAndMonotonicLifecycleOutcomes() throws Exception { - Method validator = privateMethod( - "validateReplayTransition", - ArtifactDeletionReceipt.class, - ArtifactDeletionReceipt.class, - boolean.class - ); + void replayTransitionAcceptsSnapshotFailureChecksumCaptureAndMonotonicOutcomes() throws Exception { + Method validator = replayTransitionValidator(); validator.invoke( null, receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), - receipt(ArtifactDeletionState.DELETION_REQUESTED, START.plusSeconds(1), 0, null), + receipt(ArtifactDeletionState.DELETION_REQUESTED, START.plusSeconds(1), 1, START.plusSeconds(1)), + false, true ); + validator.invoke( + null, + receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), + receipt(ArtifactDeletionState.DELETION_REQUESTED, START.plusSeconds(1), 0, null), + true, + false + ); validator.invoke( null, receipt(ArtifactDeletionState.DELETION_REQUESTED, START, 0, null), receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START.plusSeconds(1), 0, null), + false, false ); validator.invoke( null, receipt(ArtifactDeletionState.METADATA_TOMBSTONED, START, 0, null), receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 0, null), + false, false ); validator.invoke( null, receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START, 0, null), receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, START.plusSeconds(1), 0, null), + false, false ); validator.invoke( @@ -133,12 +134,14 @@ void replayTransitionAcceptsChecksumCaptureAndMonotonicLifecycleOutcomes() throw 1, START.plusSeconds(1) ), + false, false ); validator.invoke( null, receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, START, 1, START), receipt(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, START.plusSeconds(1), 1, START), + false, false ); } @@ -244,6 +247,16 @@ void privateCodecHelpersCoverNullBlankAndPresentValues() throws Exception { assertEquals(Path.of("ledger.log"), pathOf.invoke(null, " ledger.log ")); } + private static Method replayTransitionValidator() throws NoSuchMethodException { + return privateMethod( + "validateReplayTransition", + ArtifactDeletionReceipt.class, + ArtifactDeletionReceipt.class, + boolean.class, + boolean.class + ); + } + private static Method privateMethod(String name, Class... parameterTypes) throws NoSuchMethodException { Method method = ArtifactDeletionLedger.class.getDeclaredMethod(name, parameterTypes); method.setAccessible(true); @@ -257,7 +270,7 @@ private static void assertInvalidTransition( ) { assertThrows( InvocationTargetException.class, - () -> validator.invoke(null, current, replayed, false) + () -> validator.invoke(null, current, replayed, false, false) ); } From b5a57d38bcba69c5a11e4ef686e10a5a382c1426 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 20:59:21 +0900 Subject: [PATCH 173/178] test(lifecycle): cover snapshot failure persistence and replay --- ...ctDeletionSnapshotFailureCoverageTest.java | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java new file mode 100644 index 000000000..3bc3a993e --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java @@ -0,0 +1,236 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Exercises the fail-closed durable snapshot-failure contract and every + * defensive replay-detector condition. + */ +class ArtifactDeletionSnapshotFailureCoverageTest { + + private static final Instant START = Instant.parse("2026-08-07T01:00:00Z"); + private static final UUID REQUEST_ID = + UUID.fromString("31000000-0000-0000-0000-000000000001"); + private static final UUID JOB_ID = + UUID.fromString("32000000-0000-0000-0000-000000000001"); + + @Test + void ledgerRejectsUnknownFailureCodesAndPersistsRetryEvidence() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + + assertThrows( + IllegalArgumentException.class, + () -> ledger.recordSnapshotFailure(JOB_ID, "unknown_failure", START) + ); + assertThrows( + IllegalStateException.class, + () -> ledger.recordSnapshotFailure( + JOB_ID, + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE, + START + ) + ); + + ledger.request( + REQUEST_ID, + "tenant-snapshot", + JOB_ID, + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + "cleanup-v1:snapshot", + START + ); + + ArtifactDeletionReceipt failed = ledger.recordSnapshotFailure( + JOB_ID, + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE, + START.plusSeconds(1) + ); + + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, failed.state()); + assertEquals(1, failed.attemptCount()); + assertEquals(START.plusSeconds(1), failed.lastAttemptAt()); + assertEquals(START.plusSeconds(1), failed.stateChangedAt()); + assertEquals(ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE, failed.failureCode()); + assertNotNull(ledger.findByJobId(JOB_ID).orElseThrow()); + } + + @Test + void snapshotFailureDetectorCoversEveryFailClosedCondition() throws Exception { + Method detector = ArtifactDeletionLedger.class.getDeclaredMethod( + "isSnapshotFailureTransition", + ArtifactDeletionReceipt.class, + ArtifactDeletionReceipt.class + ); + detector.setAccessible(true); + + assertFalse(detect(detector, candidate( + ArtifactDeletionState.METADATA_TOMBSTONED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + true, + 0, + 1, + START.plusSeconds(1), + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.METADATA_TOMBSTONED, + true, + true, + true, + 0, + 1, + START.plusSeconds(1), + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + false, + true, + true, + 0, + 1, + START.plusSeconds(1), + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + false, + true, + 0, + 1, + START.plusSeconds(1), + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + false, + 0, + 1, + START.plusSeconds(1), + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + true, + 0, + 2, + START.plusSeconds(1), + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + true, + 0, + 1, + null, + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + true, + 0, + 1, + START, + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + assertFalse(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + true, + 0, + 1, + START.plusSeconds(1), + START.plusSeconds(1), + "different_failure" + ))); + assertTrue(detect(detector, candidate( + ArtifactDeletionState.DELETION_REQUESTED, + ArtifactDeletionState.DELETION_REQUESTED, + true, + true, + true, + 1, + 2, + START.plusSeconds(2), + START.plusSeconds(2), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ))); + } + + private static SnapshotCandidate candidate( + ArtifactDeletionState currentState, + ArtifactDeletionState replayedState, + boolean currentPending, + boolean replayedPending, + boolean sameRequestIdentity, + int currentAttempts, + int replayedAttempts, + Instant replayedLastAttemptAt, + Instant replayedStateChangedAt, + String replayedFailureCode + ) { + ArtifactDeletionReceipt current = mock(ArtifactDeletionReceipt.class); + ArtifactDeletionReceipt replayed = mock(ArtifactDeletionReceipt.class); + when(current.state()).thenReturn(currentState); + when(replayed.state()).thenReturn(replayedState); + when(current.isArtifactChecksumPending()).thenReturn(currentPending); + when(replayed.isArtifactChecksumPending()).thenReturn(replayedPending); + when(current.hasSameRequestIdentity(replayed)).thenReturn(sameRequestIdentity); + when(current.attemptCount()).thenReturn(currentAttempts); + when(replayed.attemptCount()).thenReturn(replayedAttempts); + when(replayed.lastAttemptAt()).thenReturn(replayedLastAttemptAt); + when(replayed.stateChangedAt()).thenReturn(replayedStateChangedAt); + when(replayed.failureCode()).thenReturn(replayedFailureCode); + return new SnapshotCandidate(current, replayed); + } + + private static boolean detect(Method detector, SnapshotCandidate candidate) throws Exception { + return (boolean) detector.invoke(null, candidate.current(), candidate.replayed()); + } + + private record SnapshotCandidate( + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed + ) { + } +} From 7d17391f8ddc7df6e74f21cf293f4258293e9527 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 21:05:02 +0900 Subject: [PATCH 174/178] docs(operations): explain durable restart-fair cleanup ordering --- docs/operations/artifact-deletion-cleanup.md | 41 +++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/operations/artifact-deletion-cleanup.md b/docs/operations/artifact-deletion-cleanup.md index 66e0291bf..ed2f60a5b 100644 --- a/docs/operations/artifact-deletion-cleanup.md +++ b/docs/operations/artifact-deletion-cleanup.md @@ -19,6 +19,14 @@ The worker replaces the marker with an exact SHA-256 digest, or the explicit confirmed-absence digest, before metadata tombstoning. An artifact-store read exception never becomes the absence digest. +Every failed pre-snapshot read is appended as another validated +`DELETION_REQUESTED` snapshot with a larger attempt count, a durable +`lastAttemptAt`, and the controlled `artifact_store_read_failed` code. Incomplete +work is ordered by its latest durable state or failed-attempt time. A repeatedly +unavailable artifact therefore moves behind older eligible receipts instead of +starving the bounded batch, and the same ordering is reconstructed from the +ledger after process restart without an in-memory cursor. + ## Safe operational signals `ArtifactDeletionMetrics` exposes only aggregate, dimension-free values: @@ -73,18 +81,21 @@ pending count is expected recovery evidence, not data loss. - `METADATA_TOMBSTONED`; - `ARTIFACT_CLEANUP_PENDING`; or - `ARTIFACT_CLEANUP_FAILED`. -4. Validate storage reachability, credentials, filesystem permissions, quota, +4. For pending pre-snapshot work, compare the durable `attemptCount`, + `lastAttemptAt`, and controlled failure code with later eligible receipts. + Do not infer scheduler fairness from process uptime or a memory-only cursor. +5. Validate storage reachability, credentials, filesystem permissions, quota, object-version preconditions, and timeout behavior without printing a raw document path or identifier. -5. For a pending pre-snapshot receipt, verify that no operator or adapter has +6. For a pending pre-snapshot receipt, verify that no operator or adapter has substituted the empty digest to force progress. The empty digest is valid only after the store successfully reports absence. -6. Correct the infrastructure cause and allow startup or scheduled replay to +7. Correct the infrastructure cause and allow startup or scheduled replay to resume the receipt. Do not fabricate a checksum, tombstone, or completed transition. -7. Confirm that pending count falls, completed count increases, and no new +8. Confirm that pending count falls, completed count increases, and no new controlled failure is recorded. -8. Retain only privacy-safe aggregate evidence in a buyer-shareable report. +9. Retain only privacy-safe aggregate evidence in a buyer-shareable report. Keep the raw ledger, infrastructure logs, and document identifiers in the restricted operational boundary. @@ -100,11 +111,18 @@ worker must retry the artifact read under the same lifecycle fence, durably bind the exact digest, and only then tombstone metadata. This is the expected recovery path for an outage during the very first artifact read. +A durable failed snapshot retains the same request identity and pending marker, +but its new attempt timestamp becomes the receipt's ordering time. The next +bounded pass may therefore select another older eligible receipt. Restart replay +uses those persisted snapshots and must produce the same next-eligible order; a +rollback must not restore the retired process-local rotation behavior. + Application rollback is safe only to a build that understands every receipt -format already present, including the controlled pending-marker checksum -contract. A build that does not recognize `RECEIPT_V1`, the current state -machine, or the generation fence must not start against the ledger. Preserve -artifacts and receipts until the compatible build has resumed all pending work. +format already present, including the controlled pending-marker checksum and +pre-snapshot failure-evidence contracts. A build that does not recognize +`RECEIPT_V1`, the current state machine, or the generation fence must not start +against the ledger. Preserve artifacts and receipts until the compatible build +has resumed all pending work. ## Multi-instance requirements @@ -121,7 +139,10 @@ one of the following at the adapter boundary: A plain distributed mutex without a fencing token is not sufficient evidence that a stale worker cannot publish bytes after deletion. The remote adapter must also preserve the one-way transition from pending snapshot evidence to an exact -artifact generation before any metadata tombstone becomes visible. +artifact generation before any metadata tombstone becomes visible. It must +persist failed pre-snapshot attempt evidence atomically with the ordering field +used to select the next bounded batch; a process-local cursor is not a compatible +MSA implementation. ## Acceptance evidence From 4d31bda45ec5090333d05fbcbd5840e2b45d2410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 21:08:38 +0900 Subject: [PATCH 175/178] docs(changelog): record durable restart-fair recovery --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa2f9e5c..ab3facb27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. - CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다. - Spring scheduling을 활성화하고 artifact deletion recovery를 30초 fixed delay, 실행당 최대 100개 receipt로 제한했습니다. Cleanup 완료·실패 누적값과 pending receipt 수는 dependency-free aggregate evidence로 제공하며 tenant/job/checksum/예외/경로 차원을 저장하지 않습니다. -- Bounded artifact-deletion recovery는 결정적 pending 순서를 실행마다 회전해 영구적으로 실패하는 가장 오래된 receipt 하나가 제한된 batch 뒤의 최신 cleanup을 기아 상태로 만들지 않도록 했습니다. Receipt와 상태 전이는 계속 durable source of truth로 유지하며, process restart는 안전하게 결정적 요청 순서에서 다시 시작합니다. +- Bounded artifact-deletion recovery는 실패한 pre-snapshot receipt의 attempt 수, controlled failure code, `lastAttemptAt`, 최신 durable transition time을 append-only ledger에 저장하고 그 시각으로 다음 eligible 순서를 결정합니다. 영구적으로 실패하는 가장 오래된 receipt는 bounded batch 뒤의 older eligible cleanup을 기아 상태로 만들지 않으며, process restart 후에도 ledger replay가 같은 순서를 복원하므로 process-local rotation cursor에 의존하지 않습니다. - Durable artifact deletion은 프로세스 전체 모니터 대신 공유 per-job lifecycle lock만 사용해 서로 다른 문서의 삭제와 복구를 병렬화합니다. WebFlux 관리자 DELETE는 blocking artifact I/O를 Reactor bounded-elastic worker로 격리합니다. ### Security From 1e3ae681d07cf24c8d4468a3d196a7b087066613 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 21:19:44 +0900 Subject: [PATCH 176/178] test(lifecycle): close durable recovery coverage gaps --- ...ctDeletionSnapshotFailureCoverageTest.java | 165 +++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java index 3bc3a993e..eb23c94cf 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionSnapshotFailureCoverageTest.java @@ -25,6 +25,10 @@ class ArtifactDeletionSnapshotFailureCoverageTest { UUID.fromString("31000000-0000-0000-0000-000000000001"); private static final UUID JOB_ID = UUID.fromString("32000000-0000-0000-0000-000000000001"); + private static final String TENANT_ID = "tenant-snapshot"; + private static final String AUDIT_CORRELATION_ID = "cleanup-v1:snapshot"; + private static final String ARTIFACT_CHECKSUM = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @Test void ledgerRejectsUnknownFailureCodesAndPersistsRetryEvidence() { @@ -45,10 +49,10 @@ void ledgerRejectsUnknownFailureCodesAndPersistsRetryEvidence() { ledger.request( REQUEST_ID, - "tenant-snapshot", + TENANT_ID, JOB_ID, ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, - "cleanup-v1:snapshot", + AUDIT_CORRELATION_ID, START ); @@ -66,6 +70,115 @@ void ledgerRejectsUnknownFailureCodesAndPersistsRetryEvidence() { assertNotNull(ledger.findByJobId(JOB_ID).orElseThrow()); } + @Test + void receiptRejectsSnapshotFailuresAfterBindingAndInconsistentPendingEvidence() { + ArtifactDeletionReceipt exactDigest = receipt( + ARTIFACT_CHECKSUM, + START, + 0, + null, + null + ); + + assertThrows( + IllegalStateException.class, + () -> exactDigest.recordArtifactSnapshotFailure(START.plusSeconds(1)) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt( + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + START.plusSeconds(2), + 1, + START.plusSeconds(1), + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt( + ArtifactDeletionReceipt.PENDING_ARTIFACT_CHECKSUM, + START, + 0, + null, + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE + ) + ); + } + + @Test + void initialRequestDetectorCoversEveryFailClosedCondition() throws Exception { + Method detector = ArtifactDeletionLedger.class.getDeclaredMethod( + "isInitialRequest", + ArtifactDeletionReceipt.class + ); + detector.setAccessible(true); + + assertFalse(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.METADATA_TOMBSTONED, + 0, + null, + null, + null, + START, + START + ))); + assertFalse(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.DELETION_REQUESTED, + 1, + null, + null, + null, + START, + START + ))); + assertFalse(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.DELETION_REQUESTED, + 0, + START, + null, + null, + START, + START + ))); + assertFalse(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.DELETION_REQUESTED, + 0, + null, + START, + null, + START, + START + ))); + assertFalse(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.DELETION_REQUESTED, + 0, + null, + null, + ArtifactDeletionReceipt.SNAPSHOT_READ_FAILURE_CODE, + START, + START + ))); + assertFalse(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.DELETION_REQUESTED, + 0, + null, + null, + null, + START.plusSeconds(1), + START + ))); + assertTrue(isInitialRequest(detector, initialCandidate( + ArtifactDeletionState.DELETION_REQUESTED, + 0, + null, + null, + null, + START, + START + ))); + } + @Test void snapshotFailureDetectorCoversEveryFailClosedCondition() throws Exception { Method detector = ArtifactDeletionLedger.class.getDeclaredMethod( @@ -197,6 +310,54 @@ void snapshotFailureDetectorCoversEveryFailClosedCondition() throws Exception { ))); } + private static ArtifactDeletionReceipt receipt( + String checksum, + Instant stateChangedAt, + int attemptCount, + Instant lastAttemptAt, + String failureCode + ) { + return new ArtifactDeletionReceipt( + REQUEST_ID, + TENANT_ID, + JOB_ID, + checksum, + AUDIT_CORRELATION_ID, + START, + stateChangedAt, + ArtifactDeletionState.DELETION_REQUESTED, + attemptCount, + lastAttemptAt, + null, + failureCode + ); + } + + private static ArtifactDeletionReceipt initialCandidate( + ArtifactDeletionState state, + int attemptCount, + Instant lastAttemptAt, + Instant completedAt, + String failureCode, + Instant stateChangedAt, + Instant requestedAt + ) { + ArtifactDeletionReceipt receipt = mock(ArtifactDeletionReceipt.class); + when(receipt.state()).thenReturn(state); + when(receipt.attemptCount()).thenReturn(attemptCount); + when(receipt.lastAttemptAt()).thenReturn(lastAttemptAt); + when(receipt.completedAt()).thenReturn(completedAt); + when(receipt.failureCode()).thenReturn(failureCode); + when(receipt.stateChangedAt()).thenReturn(stateChangedAt); + when(receipt.requestedAt()).thenReturn(requestedAt); + return receipt; + } + + private static boolean isInitialRequest(Method detector, ArtifactDeletionReceipt receipt) + throws Exception { + return (boolean) detector.invoke(null, receipt); + } + private static SnapshotCandidate candidate( ArtifactDeletionState currentState, ArtifactDeletionState replayedState, From d7210063f9a8cfd6d3ed1ba0695a13fa730d291b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 21:33:24 +0900 Subject: [PATCH 177/178] test(privacy): reject exception-selected deletion log details --- ...rtifactDeletionCoordinatorPrivacyTest.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorPrivacyTest.java diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorPrivacyTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorPrivacyTest.java new file mode 100644 index 000000000..e79d86815 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinatorPrivacyTest.java @@ -0,0 +1,149 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Verifies that durable-deletion operational warnings expose only controlled + * event text and never exception-selected implementation details. + */ +class ArtifactDeletionCoordinatorPrivacyTest { + + private static final String TENANT_ID = "tenant-private-warning"; + private static final UUID SNAPSHOT_JOB_ID = + UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID RECOVERY_JOB_ID = + UUID.fromString("52000000-0000-0000-0000-000000000001"); + + @Test + void operationalWarningsExcludeExceptionSelectedClassNames() { + CapturingAppender appender = attachAppender(); + try { + triggerPreSnapshotReadFailure(); + triggerRecoveryFailure(); + } finally { + appender.closeAndDetach(); + } + + assertEquals( + List.of( + "Artifact deletion retained a pre-snapshot receipt.", + "Artifact deletion recovery retained an incomplete receipt." + ), + appender.messages() + ); + assertFalse( + appender.messages().stream() + .anyMatch(message -> message.contains(SensitiveStorageImplementationException.class.getName())) + ); + } + + private static void triggerPreSnapshotReadFailure() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ConversionJob job = mock(ConversionJob.class); + when(repository.findByTenantAndId(TENANT_ID, SNAPSHOT_JOB_ID)) + .thenReturn(Optional.of(job)); + ArtifactStore artifactStore = mock(ArtifactStore.class); + when(artifactStore.getPdf(SNAPSHOT_JOB_ID)) + .thenThrow(new SensitiveStorageImplementationException()); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + + coordinator(repository, artifactStore, ledger).deleteForTenant(SNAPSHOT_JOB_ID, TENANT_ID); + } + + private static void triggerRecoveryFailure() { + ArtifactDeletionReceipt receipt = mock(ArtifactDeletionReceipt.class); + when(receipt.jobId()).thenReturn(RECOVERY_JOB_ID); + ArtifactDeletionReceiptStore receiptStore = mock(ArtifactDeletionReceiptStore.class); + when(receiptStore.pendingReceipts()).thenReturn(List.of(receipt)); + when(receiptStore.findByJobId(RECOVERY_JOB_ID)) + .thenThrow(new SensitiveStorageImplementationException()); + + coordinator( + mock(ConversionJobRepository.class), + mock(ArtifactStore.class), + receiptStore + ).retryPendingWork(); + } + + private static ArtifactDeletionCoordinator coordinator( + ConversionJobRepository repository, + ArtifactStore artifactStore, + ArtifactDeletionReceiptStore receiptStore + ) { + return new ArtifactDeletionCoordinator( + repository, + artifactStore, + receiptStore, + new ArtifactDeletionMetrics(receiptStore), + 100 + ); + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(ArtifactDeletionCoordinator.class); + CapturingAppender appender = new CapturingAppender(logger, logger.getLevel()); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.WARN); + return appender; + } + + private static final class SensitiveStorageImplementationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final Level originalLevel; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger, Level originalLevel) { + super( + "artifact-deletion-privacy-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + this.originalLevel = originalLevel; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private List messages() { + return List.copyOf(messages); + } + + private void closeAndDetach() { + logger.removeAppender(this); + logger.setLevel(originalLevel); + stop(); + } + } +} From 8d3fa3d1e7037c1c91411e8b4b6c014e38d04add Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 21:37:38 +0900 Subject: [PATCH 178/178] fix(privacy): remove exception classes from deletion warnings --- .../lifecycle/ArtifactDeletionCoordinator.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java index 3dae44104..df4a5265e 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionCoordinator.java @@ -170,12 +170,9 @@ public synchronized int retryPendingWork() { for (int index = 0; index < selected; index++) { try { resumeReceipt(pending.get(index)); - } catch (RuntimeException exception) { + } catch (RuntimeException ignored) { metrics.recordFailed(); - log.warn( - "Artifact deletion recovery retained an incomplete receipt. cause={}", - exception.getClass().getName() - ); + log.warn("Artifact deletion recovery retained an incomplete receipt."); } } return selected; @@ -283,13 +280,10 @@ private Optional trySnapshotChecksum(UUID jobId) { artifactStore.getPdf(jobId), "artifactStore.getPdf" ); - } catch (RuntimeException exception) { + } catch (RuntimeException ignored) { receiptStore.recordSnapshotFailure(jobId, FAILURE_READ, Instant.now()); metrics.recordFailed(); - log.warn( - "Artifact deletion retained a pre-snapshot receipt. cause={}", - exception.getClass().getName() - ); + log.warn("Artifact deletion retained a pre-snapshot receipt."); return Optional.empty(); } return Optional.of(artifact.map(ArtifactDeletionCoordinator::sha256)