Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다.
- `RECEIPT_V1` append-only artifact-deletion receipt foundation을 추가했습니다. 영구 `job_id`, tenant, artifact SHA-256, idempotency request, privacy-safe audit correlation, 단조 상태와 시각, controlled failure code를 하나의 불변 증거로 묶고 standalone in-memory 및 restart-replay file adapter를 같은 `ArtifactDeletionReceiptStore` 계약으로 제공합니다. HTTP 삭제·metadata tombstone·cleanup worker의 원자적 통합은 issue #263의 후속 slice로 유지합니다.
- **관리자용 단건 작업 삭제 및 재시도 API 추가**
- 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다.
- 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다.
Expand All @@ -25,6 +26,7 @@
### Security

- `GET /api/v1/convert/jobs/{jobId}/download`가 리소스 조회 전에 전용 `artifact:read` 권한을 검증하고, PDF 저장소 접근 전에 작업의 tenant 소유권을 확인하도록 강화했습니다. `job:read`만으로는 문서 바이트를 읽을 수 없으며, 인증 누락·권한 누락·교차 tenant UUID 접근은 각각 fail closed 처리되고 교차 tenant 요청은 리소스 존재를 숨기는 `404`를 반환합니다.
- Deletion receipt ledger는 각 transition snapshot을 16 KiB 이하 strict UTF-8 `RECEIPT_V1` record로 검증하고 append 후 `FileChannel.force(true)`를 완료하기 전 durable 상태를 반환하지 않습니다. Record terminator는 host-independent fixed LF로 고정하고 commit delimiter로 취급하므로 non-empty unterminated final tail은 crash 또는 torn append의 미확정 증거로 간주해 startup에서 fail closed 처리하고 replay하지 않습니다. Replay는 `attempt_count`와 `last_attempt_at`의 동시 존재, 시각 순서, requested/tombstoned 상태의 zero-attempt 계약, retry/completion의 이전 attempt evidence 보존을 검증하며 pending-to-failed 전이만 count 증가를 허용합니다. malformed Base64URL, invalid timestamp/state/count, immutable identity conflict, non-monotonic time, unsafe failure detail, oversized line도 거부합니다. 이 증거는 아직 cleanup 완료를 의미하지 않으며 issue #263의 transactional outbox와 exact-generation worker가 통합될 때까지 incomplete cleanup finding을 유지합니다.
- Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다.
- Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다.
- Maven XML 테스트 보고서 검증기는 파일당 16 MiB 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다.
Expand Down
169 changes: 169 additions & 0 deletions docs/security/2026-08-06-durable-artifact-deletion-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Durable artifact-deletion receipt boundary

- **Status:** Accepted foundation; HTTP mutation and cleanup-worker integration remain future slices.
- **Decision date:** 2026-08-06
- **Owner issue:** #263
- **Format version:** `RECEIPT_V1`

## Decision

Clearfolio records each accepted artifact-deletion lifecycle as immutable,
versioned receipt snapshots. The reference adapter is an append-only UTF-8 ledger
that forces each snapshot to storage before returning. A standalone caller may
use the in-memory constructor, while the Spring application defaults to
`data/artifact-deletion-receipts.log` so pending evidence can be replayed after a
process restart.

This foundation does **not** yet change the administrative DELETE response, move
job tombstoning and receipt creation into one transaction, revoke signed links,
or run an artifact-cleanup worker. Those are separate bounded slices under
issue `#263`. Until they integrate, the existing delete path remains best effort
and the CodeRabbit incomplete-cleanup finding must remain open.

## Immutable receipt identity

One receipt identity contains:

- `request_id`: deletion idempotency identifier;
- `tenant_id`: tenant that owned the conversion job;
- `job_id`: permanently reserved conversion-job identifier;
- `artifact_checksum`: lowercase SHA-256 digest for the exact artifact bytes;
- `audit_correlation_id`: privacy-safe correlation value, never a raw subject,
tenant secret, token, filename, or document value;
- `requested_at`: durable request time.

A repeated request returns the existing object only when every immutable field
matches. A different request, tenant, artifact digest, audit correlation, or
request time for the same `job_id` fails closed. This complements #268's
permanent UUID reservation and prevents cleanup work from being rebound to a
new tenant or artifact generation.

## Monotonic lifecycle

```mermaid
stateDiagram-v2
[*] --> DELETION_REQUESTED
DELETION_REQUESTED --> METADATA_TOMBSTONED
METADATA_TOMBSTONED --> ARTIFACT_CLEANUP_PENDING
ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_COMPLETED
ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_FAILED
ARTIFACT_CLEANUP_FAILED --> ARTIFACT_CLEANUP_PENDING
ARTIFACT_CLEANUP_COMPLETED --> [*]
```

Every snapshot records `state_changed_at`. Time cannot move backward relative to
the previous durable transition. `ARTIFACT_CLEANUP_COMPLETED` is terminal.
Failures increment `attempt_count`, record `last_attempt_at`, and accept only a
controlled lowercase code matching `[a-z0-9_]{1,64}`. Exception messages,
storage paths, raw identifiers, stack traces, and document metadata are not
valid failure codes.

Attempt evidence is internally consistent and immutable across replay:

- `attempt_count == 0` requires absent `last_attempt_at`;
- `attempt_count > 0` requires a `last_attempt_at` no later than
`state_changed_at`;
- requested and metadata-tombstoned receipts cannot claim cleanup attempts;
- only pending-to-failed increments the count and advances `last_attempt_at`;
- retry-pending and completed snapshots preserve the prior count and latest
attempt instant exactly.

A replayed snapshot that erases, rewrites, advances, or invents attempt evidence
outside those transitions fails closed.

## Persistence and replay

The reference file adapter applies the following contract:

1. serialize one complete `RECEIPT_V1` snapshot;
2. encode variable text fields as Base64URL and separate fields with tabs;
3. reject records longer than 16 KiB during replay;
4. write the full record and a fixed ASCII LF (`0x0A`) terminator through one
append-only file channel, treating LF as the record commit delimiter;
5. call `FileChannel.force(true)` before exposing the transition as durable;
6. replay only delimiter-committed records with bounded, strict UTF-8 decoding;
7. reject any non-empty unterminated final tail instead of interpreting a
potentially torn or pre-force append as durable evidence;
8. validate record shape, immutable identity, state timestamps, attempt evidence,
and legal predecessor-to-successor transitions;
9. fail application construction on malformed, conflicting, oversized,
unterminated, or non-monotonic evidence.

A missing ledger file means an empty store. A malformed existing file or
unterminated final record is not silently skipped, truncated, or replayed.
Startup does not mutate audit evidence as an implicit recovery action; operators
must preserve the original bytes and use a controlled, reviewable recovery
procedure. Completed receipts are retained for audit and idempotency but
excluded from the pending-work view.

## Modular and MSA contract

`ArtifactDeletionReceiptStore` is the versioned service boundary. An external
PostgreSQL, event-store, or message-broker adapter may replace the file ledger,
but it must preserve:

- one permanently reserved `job_id` lifecycle;
- exact immutable request identity;
- tenant and artifact-digest binding;
- monotonic state and time;
- idempotent duplicate requests;
- controlled privacy-safe failure evidence;
- restart-replay of pending and failed work;
- terminal completion retention;
- fail-closed conflict behavior.

Future database objects must use descriptive two-or-more-word `snake_case`
names, including `deletion_request`, `deletion_receipt`,
`artifact_cleanup_task`, `job_tombstone`, and `audit_event`.

## Required next slices

### Atomic tombstone and outbox

Create the receipt, metadata tombstone, and cleanup task in one durable
transaction. A crash must not leave a tombstone without a replayable task or a
task without an authorized tombstone.

### Exact-generation cleanup worker

The worker must revalidate tenant, `job_id`, artifact checksum or storage
generation, receipt state, and revocation state before deletion. Stale receipts
must be rejected rather than deleting newer bytes.

### Truthful API and accessible UI

The product must distinguish accepted, cleanup pending, cleanup failed, and
completed states. It must never return a completed deletion while confidential
artifact bytes or valid signed links remain available.

## Verification requirements

- exact duplicate request object identity;
- conflicting same-job requests fail closed;
- every legal and illegal transition;
- nondecreasing transition timestamps;
- internally consistent and transition-preserved attempt evidence;
- controlled failure-code validation;
- attempt-count persistence across retries and restart;
- strict UTF-8, malformed-line, oversized-line, conflicting-replay, and
unterminated-final-tail rejection;
- fixed host-independent LF record delimiters;
- completed receipts excluded from pending work but retained after restart;
- production line and branch coverage of 100%;
- complete beginner-readable public Javadocs;
- exact-head CI, Security Scan, SAST, fuzzing, independent review, and protected
merge evidence before integration.

## References

Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110;
STD 97). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9110

National Institute of Standards and Technology. (2024). *Protecting controlled
unclassified information in nonfederal systems and organizations* (NIST Special
Publication 800-171, Revision 3). U.S. Department of Commerce.
https://doi.org/10.6028/NIST.SP.800-171r3

OWASP Foundation. (2023). *API1:2023 broken object level authorization*. In
*OWASP API Security Top 10—2023*.
https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
24 changes: 24 additions & 0 deletions scripts/test_ci_workflow_stack_coverage.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading