Files
tech-log-backend/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md

3480 lines
179 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Object Storage Production Capability Implementation Plan
- 작성일: 2026-07-28
- 상태: Batch AD 및 Batch E pause checkpoint를 main에 통합, Batch E 전체 gate와 R2는 미완료
- 범위: 상세 설계 Phase 06; Phase 7/R3는 별도 승인 계획
- LLM Wiki capture: public vault의
`raw/branch-notes/chore-main-worktree-capability-integration.md`에 main 통합 기록 완료
> **For agentic workers:** REQUIRED SUB-SKILLS: use
> `superpowers:subagent-driven-development` to execute independent tasks,
> `superpowers:test-driven-development` for every behavior change, and
> `superpowers:verification-before-completion` before changing any readiness claim. Track progress
> with the checkboxes in this document. Repository policy is `human-only`: do not stage, commit,
> amend, or push.
**Goal:** Replace the caller-keyed, whole-object `byte[]` example with a framework-free,
bounded-streaming object publication capability, migrate the sample Poster workflow through a
durable database/object-storage handoff, and qualify only exact provider/card combinations for
which the required evidence exists.
**Architecture:** `application-core` owns provider-neutral identities, content callbacks, requests,
receipts, outcomes, and narrow outbound ports under `dev.caskeleton.application.objectstorage`.
`adapter:outbound:objectstorage` owns immutable data/control namespaces, canonical codecs,
operation state machines, provider bindings, filesystem/S3 implementations, reconciliation, and
readiness evidence. The legacy `dev.caskeleton.application.storage` CRUD contract remains isolated
until the sample and stored data have migrated. `sample-portfolio` owns the business `UploadIntent`,
database attachment choreography, and public HTTP contract; the object-storage adapter never reads
the sample database.
**Tech Stack:** Java 21, Spring Boot 4.0.0 configuration properties/autoconfiguration, Gradle
multi-module verification, JUnit 5, AssertJ, jqwik `1.9.1`, AWS SDK for Java v2 `2.30.0`,
`S3AsyncClient` with the Java Netty NIO HTTP client, Testcontainers `2.0.2`, version-pinned MinIO,
Toxiproxy, PostgreSQL/Flyway for the sample workflow, Micrometer/Actuator.
**Authoritative design:**
[Object Storage Production Capability Deep Design](../specs/2026-07-28-objectstorage-production-capability-design.md).
When this plan and that design differ, stop and amend the design before implementing. Module edges
remain governed only by `src/config/architecture/modules.json`.
**Scope boundary:** This is the executable master plan for design Phases 06. It deliberately does
not claim that all tasks belong in one pull request or release. Phase 7 multi-node/R3 work, a
provider-leaf split, a production malware-scanner implementation, and browser API product choices
outside the approved contracts require follow-up plans. Writing this plan changes no runtime
behavior and advances no readiness card.
**Implementation checkpoint (2026-07-28):** The Batch AB application/kernel/local-provider
baseline and the Batch CD S3/MinIO managed/direct primitive slices are implemented and verified in
`codex/objectstorage-production-capability`. Exact MinIO qualification proved that create-only PUT
and multipart completion conditions are ignored, so its managed/direct mutation profiles remain
`UNSUPPORTED`. AWS lanes compile only and have no observed evidence. Only the exact local managed
single upload/download cards remain R1; every direct, multipart, quarantine, retention, and
production reconciliation card remains R0. The exhaustive R2 fault/race matrices named by
unchecked steps remain follow-up evidence, not implied completion. The legacy provider and Poster
consumer remain isolated behind explicit sample-local/test activation. Evidence:
[Batch A](../../evidence/object-storage/batch-a-checkpoint.md),
[Batch B](../../evidence/object-storage/batch-b-checkpoint.md), and
[Batch C](../../evidence/object-storage/batch-c-progress.md),
[Batch D](../../evidence/object-storage/batch-d-checkpoint.md).
Batch E의 개별 focused fix 증거는
[Batch E pause checkpoint](../../evidence/object-storage/batch-e-pause-checkpoint.md)에 기록됐다.
당시 Batch E combined/root gate는 재실행되지 않았으므로 Batch E 전체 완료나 R2를 주장하지
않는다. 격리 브랜치에서 잘못 선택했던 private vault 경로 대신, main 통합 작업은 사용자가
지정한 public vault의 `raw/branch-notes/chore-main-worktree-capability-integration.md`
캡처했다.
---
## Execution rules
1. Execute batches in order. A later batch may start only after the preceding checkpoint passes.
2. Within a task, write the named failing test first, run the stated RED command, make the minimum
implementation, then run the same command GREEN.
3. A compilation failure counts as RED only when the missing symbol is the symbol the task is
intentionally introducing. Dependency, formatting, daemon, Docker, credential, or unrelated
compilation failures do not count.
4. Preserve the legacy port as an isolated compatibility seam. New business code must never import
`dev.caskeleton.application.storage`.
5. Do not expose AWS SDK, Spring, `Path`, bucket, raw key, provider endpoint, raw ETag, upload ID,
persistent provider locator, or inbound DTO types through `application-core`. A bounded
presigned URI exists only in the explicit transient direct-grant value, is always redacted, and
is never persisted in a receipt/control record. The only raw-locator exception is the exact,
deprecated, admin-only `dev.caskeleton.application.storage.migration` adoption seam in Task 24;
its value is bounded, redacted, never logged/serialized into a receipt, and may not be injected
into a normal business use case.
6. Do not call a producer while a database transaction is open. Do not claim that a database
rollback reverses an object mutation.
7. Unsupported provider behavior is a typed rejection or startup failure, never a fallback,
check-then-overwrite emulation, test skip, or weaker silent guarantee.
8. `filesystem-local-dev` and the pinned MinIO topology have an R1 ceiling. AWS S3 also remains
below R2 until the Phase 6 protected qualification lane passes for an exact provider, destination
profile, card, and evidence revision.
9. Run Spotless only on files changed by this plan. Do not format or rewrite unrelated dirty files.
10. At every batch boundary, update the implementation-status section in the design and this plan.
Do not mark a task complete from code inspection alone.
11. Every Batch AF checkpoint is a meaningful-work capture boundary. Re-read the canonical LLM
Wiki instructions, update the branch note and any honest derivatives, or record the exact
canonical-vault access block in both this plan and the design before starting the next batch.
A final Task 30 capture does not replace these per-batch records.
## Frozen implementation decisions
These decisions translate §37 of the design into executable constraints. A row marked
**approval gate** is intentionally not delegated to an implementation task.
| Concern | Decision for this plan |
| --- | --- |
| Application package | New contract lives under `dev.caskeleton.application.objectstorage` with `identity`, `content`, `model`, `request`, and `port` subpackages. |
| Legacy coexistence | `dev.caskeleton.application.storage.ObjectStoragePort` and `StoredObject` become deprecated legacy-only types. They remain readable until sample data migration, dual-read observation, API snapshot approval, and zero production usages are evidenced. |
| Control format | `canonical-json-v1`: UTF-8, fixed field order, no insignificant whitespace, decimal integers, canonical enum names, strict duplicate/unknown-field rejection, bounded record-family sizes, and an outer SHA-256 corruption digest. R2 relies additionally on private namespace/IAM and provider encryption; the digest is not described as tamper authentication. |
| Record sizing | Operation/reference/session records are at most 64 KiB; terminal receipts at most 16 KiB; multipart part receipts are separate immutable records of at most 4 KiB each. A 10,000-part ledger is never materialized into one control object. |
| Conditional CAS | S3 uses `PutObject` `If-None-Match: *` for reserve and exact private ETag `If-Match` for revision CAS. The pinned SDK API must be characterized before use. MinIO must prove the same semantics in a non-skipping integration test. Filesystem local-dev uses process lock plus exclusive create/atomic replace and therefore stays R1. |
| Public reference | `osr1.<route>.<object>.<check>` where route is 12 lowercase Crockford Base32 characters, object is 26 lowercase Crockford Base32 characters (128 random bits), and check is the first 10 lowercase hex characters of SHA-256 over the first three components. Total parsing is bounded; the value contains no provider locator and is not authorization. |
| Stage/session handles | Separate prefixes (`osh1`, `osu1`, `osm1`) and the same bounded route/random/check structure. A stage or session handle is rejected by published-reference parsers and public read ports. |
| Operation deadline | Reuse `dev.caskeleton.application.outbound.CallBudget` as the absolute monotonic parent budget. Add a framework-free `CancellationView`; never serialize either value into durable control records. Persist only wall-clock attempt/lease timestamps and bounded policy durations. |
| Async S3 HTTP | Use `S3AsyncClient` with an explicitly configured `NettyNioAsyncHttpClient`. Do not use CRT, `S3TransferManager`, or SDK-owned automatic multipart for an R2 card. |
| AWS SDK | Keep the repository SSOT `awsSdkVersion = 2.30.0` for this plan. Characterize checksum defaults, conditional builders, and the `mpuObjectSize(Integer)` boundary. A version upgrade needs its own dependency/evidence decision. |
| Local-dev root | No code default. Unit tests use `@TempDir`; the explicit sample-local profile may bind `./.data/object-storage-v1`. Production profiles reject `filesystem-local-dev`. |
| MinIO identity | Initial test identity is `s3-compatible-minio-community-release-2024-01-16t16-07-38z`, corresponding to the existing `minio/minio:RELEASE.2024-01-16T16-07-38Z`. Phase 3 acceptance requires conversion to an image digest pin and records that digest as provider-version evidence. |
| Provider IDs | Only `filesystem-local-dev`, `aws-s3-general-purpose`, and the exact MinIO identity above are implemented in this plan. `filesystem-local-persistent` remains a follow-up provider. |
| Binding prefix | Canonical prefix is `app.object-storage`; `enabled` defaults to `false`, and there is no default provider or destination. Any simultaneous `ca-skeleton.objectstorage.*` and canonical configuration fails startup without logging values. |
| Composition SPI | Task 9 creates a side-effect-free provider-contribution registry. Settings are fully compiled before the assembler asks only selected contributions to construct clients. Normal semantic routers, scan-maintenance routers, and privileged purge routers are distinct concrete types/configurations; no catch-all router exposes a privileged port in a normal application context. Every later provider slice updates its explicit contribution and selected/unselected/disabled composition test—component scanning is not provider activation. |
| Bootstrap edge | Do not add an `app-bootstrap -> adapter-outbound-objectstorage` edge in this plan: there is no production use-case owner there. The sample already has the registered runtime-only edge. A future production owner requires a separate registry/Gradle approval. |
| Scanner owner | Application contracts and staged scan fencing are implemented; tests use a fake scanner verdict source. Selecting and implementing a production scanner is an **approval gate** and is required before the quarantine-publication card can claim R2. |
| Sample durable work | Use a dedicated `poster_image_upload_intent` table/repository and reconciliation use case. Do not reuse broker-delivery outbox rows as an object-storage operation journal. Object bytes never enter that table. |
| API compatibility | Preserve the existing `/posters/{id}/image` response during the legacy window. A new opaque-reference response and any upload-session endpoints require an **approval gate** plus intentional OpenAPI snapshot regeneration; no task silently removes `key`/`location`. |
| Direct-card ceiling | This plan implements provider/session primitives but no approved public direct-upload/download/session endpoint. Therefore direct single, direct multipart, and direct-download cards remain at most R1/partial and Task 29 may not promote them to R2. A follow-up inbound authorization/rate-limit/API snapshot plan is required. |
| Readiness registry | Add `docs/registries/object-storage-readiness.yaml`, schema version 1, with exact `card_id`, provider type/version, destination profile, claimed level, evidence revision/expiry, required non-skipping Gradle tasks, and limitations. Runtime descriptors are derived from compiled binding plus live qualification; the registry is the CI claim manifest, not a substitute for probes. |
| Cleanup ownership | Maintenance lease is `(destination route, job id, owner token, monotonically increasing fence, expires-at)`. Every destructive cleanup also needs an exact object/version precondition and an application handoff/abort authorization. Age or LIST absence alone never authorizes deletion. Report-only is the default. |
| Phase 7 | Phase 6 must still prove bounded backup/restore reconciliation for every exact R2 reconciliation card in a disposable namespace. Regional/cluster disaster-recovery game days, multi-node failover/fencing, sustained scale, and the provider-leaf split/no-split ADR are excluded and require a new approved plan. |
## Batch graph and promotion gates
```text
Batch A: Phase 01 contract
-> Batch B: Phase 2 provider-neutral kernel + local R1
-> Batch C: Phase 3 managed S3/MinIO common subset
-> Batch D: Phase 4 direct transfer + multipart
-> Approval Gate A: scanner owner + sample API contract
-> Batch E: Phase 5 staged publication + sample migration
-> Approval Gate B: AWS sandbox/IaC/workload identity
-> Batch F: Phase 6 exact R2 security/maintenance/readiness
-> separate Phase 7 plan
```
| Checkpoint | Minimum acceptance | Rollback posture |
| --- | --- | --- |
| A | Application contracts are pure; legacy behavior is characterized; no provider types leak. | Keep new binding disabled and old consumer unchanged. |
| B | Local-dev passes bounded provider contract and restart characterization; only R1 is published. | Disable canonical capability; legacy local example remains isolated. |
| C | Async managed path, CAS, response-loss resolution, and adapter-owned multipart pass pinned MinIO; the AWS lane is compile-only/authority-pending with no observed evidence. | Rebind only new destinations to the prior qualified provider; old route revisions stay readable. |
| D | Direct grant/session ledgers pass expiry, redaction, completion, and late-request races; no public endpoint is implied. | Stop issuing grants, drain/expire/reconcile existing sessions, keep managed transfer. |
| E | Database intent precedes remote mutation; every crash gap is tested; no unscanned data becomes public; old locators remain dual-readable only during migration. | Stop new admission, drain intents, preserve published-reference reads, do not delete legacy data. |
| F | Each claimed card has exact non-skipping security/fault/real-provider evidence and runbooks. | Disable card admission or maintenance delete, retain published reads and the manual reconciliation queue. |
Every checkpoint named below has three inseparable outputs: (1) the stated GREEN commands and
zero-selected-skip evidence, (2) updated plan/design implementation status and truthful readiness
rows, and (3) a canonical LLM Wiki branch-note update or the exact canonical-vault access block.
This applies to A at Task 5, B at Task 10, C at Task 16, D at Task 19, E at Task 24, and F at
Task 30; a batch is not closed if any output is missing.
---
## Batch A — Phase 01: Truth and framework-free contract
### Task 1: Characterize the legacy boundary without changing behavior
**Files:**
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java`
- Create:
`docs/evidence/object-storage/phase-0-inventory.md`
- [x] **Step 1: Record current executable behavior**
Prove the current contract exactly as-is:
- repeated `put` to the same caller key overwrites;
- `get` materializes the whole object;
- filesystem returns `file://` and S3 returns `s3://`;
- absent `ca-skeleton.objectstorage.backend` creates a filesystem bean and directory during
application-context construction, before the first `put`;
- S3 `autoCreateBucket=true` can provision at startup;
- Poster calls storage while `TransactionPort.inWrite` is active;
- the controller calls `MultipartFile.getBytes`;
- the response exposes raw key/location;
- Poster deletion does not retire the object.
- [x] **Step 2: Verify the characterization baseline**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*LegacyObjectStorageBehaviorTest' \
--tests '*LegacyObjectStorageConfigTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*LegacyPosterImageUploadCharacterizationTest' \
--console=plain
```
Expected: PASS against the current implementation. This is a baseline, not R1/R2 evidence.
- [x] **Step 3: Inventory runtime and data dependencies**
In the evidence document record command output, not an unsupported repository-wide conclusion:
```bash
rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \
src docs
rg -n 'image_key|posters/.*/image' src/sample-portfolio
```
Inventory the known producers/consumers and owner evidence for
`poster.image-attached`, `/posters/{id}/image`, `StoredObjectResponse`, and
`PosterResponse.imageKey`. Repository search proves only repository usages; unknown external
broker/REST consumers are recorded as unknown and block Gate A removal/versioning approval.
Classify every hit as legacy runtime, test, documentation, stored-data schema, or unrelated text.
Record whether real deployed consumers/data were inspected; if they were not, state that external
inventory is still required.
- [x] **Step 4: Run unchanged focused suites**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain
```
Expected: PASS. No source behavior changes belong to this task.
### Task 2: Add bounded object-storage identities and opaque references
**Files:**
- Modify:
`src/application-core/build.gradle`
- Modify:
`src/application-core/gradle.lockfile`
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/`:
`ObjectDestinationId.java`, `ObjectOperationEpoch.java`, `ObjectOperationId.java`,
`ObjectOperationKey.java`, `ObjectId.java`, `ObjectReference.java`, `ObjectStageHandle.java`,
`ObjectVersionToken.java`, `DirectTransferSessionId.java`, `MultipartPartNumber.java`,
`PartReceiptToken.java`
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java`
- [x] **Step 1: Add the approved test-only property engine**
Add `testImplementation 'net.jqwik:jqwik:1.9.1'`, matching the existing sample test version, and
update only the application-core lockfile:
```bash
cd src
./gradlew :application-core:resolveAndLockAll --write-locks
./gradlew :application-core:verifyDependencyLocks --console=plain
```
Expected: PASS with test-only jqwik entries and no production dependency.
- [x] **Step 2: Write the failing identity contract**
Test null/blank/control-character/oversize rejection, canonical round trips, operation-key
composition, part range `1..10_000`, prefix separation, route-token grammar, reference check-digit
tampering, and provider-locator non-disclosure. Include jqwik properties for arbitrary malformed
input and a fixed golden vector for each prefix. Route existence/retirement is adapter binding
state and is deliberately not tested or imported in `application-core`.
- [x] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test \
--tests '*ObjectStorageIdentityContractTest' --console=plain
```
Expected: compilation failure because the new identity types do not exist.
- [x] **Step 4: Implement the minimum values**
Use immutable final classes or records with constructor validation. `ObjectOperationKey` contains
exactly destination, epoch, and operation ID. Public reference/handle types expose canonical text
and a redacted log token only; they do not expose parsed provider coordinates. Keep check-digit
validation in framework-free Java (`MessageDigest`). The adapter-owned codec in Task 6 is the only
minting path from a retained route token plus generated `ObjectId`; application values do not
consult a route registry.
- [x] **Step 5: Verify GREEN**
Run the command from Step 3 and
`./gradlew :application-core:verifyDependencyLocks --console=plain`. Expected: PASS.
### Task 3: Add bounded streaming, digest, range, and cancellation contracts
**Files:**
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/`:
`ObjectContentProducer.java`, `ObjectChunkSink.java`, `ObjectContentConsumer.java`,
`ObjectChunkSource.java`, `ObjectContentProductionContext.java`,
`ObjectContentReadContext.java`, `CancellationView.java`,
`ObjectContentProductionException.java`, `ObjectChunkWriteException.java`,
`ObjectContentConsumptionException.java`, `ObjectChunkReadException.java`
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/`:
`ObjectDigestAlgorithm.java`, `ObjectDigest.java`, `ObjectContentIdentity.java`,
`ObjectMediaType.java`, `ObjectReadRange.java`, `ObjectDigestVerification.java`
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectContentContractTest.java`
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java`
- [x] **Step 1: Write failing callback and value tests**
Prove:
- source EOF is `-1`, zero-length calls and array ranges are validated, and bounded repeated
zero-progress reads fail;
- sink/source cannot be retained and used after callback return;
- contexts carry `CallBudget`, `CancellationView`, maximum chunk bytes, and validated read
descriptor/range only;
- SHA-256 is the baseline logical digest and is distinct from provider transport checksum/ETag;
- exact length plus digest is required for R2 `ObjectContentIdentity`;
- range offset/length arithmetic rejects zero, negative, overflow, and over-budget delivery;
- media types are canonical, bounded, and contain no control characters.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test \
--tests '*ObjectContentContractTest' \
--tests '*ObjectStorageValueContractTest' --console=plain
```
Expected: compilation failure because the callback/value types do not exist.
- [x] **Step 3: Implement the minimum contract**
Reuse `CallBudget`; do not create serializable wall-clock deadlines. Callback types may declare only
application exceptions and Java primitive/array types. Do not expose `InputStream`, `OutputStream`,
`ByteBuffer`, Reactor, Flow, servlet, Spring, or AWS types. Document that callbacks are synchronous
and blocking and that adapters must not invoke application callbacks on SDK event-loop threads.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
### Task 4: Add the semantic request, receipt, outcome, and port family
**Files:**
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/`:
`ManagedObjectPublicationPort.java`, `ObjectInspectionPort.java`,
`ObjectTransferPort.java`, `ObjectRetirementPort.java`,
`ObjectPurgeMaintenancePort.java`, `ObjectOperationResolutionPort.java`,
`ObjectPublicationHandoffPort.java`, `DirectObjectUploadPort.java`,
`DirectObjectDownloadGrantPort.java`, `DirectMultipartUploadPort.java`,
`StagedObjectPublicationPort.java`, `ObjectScanMaintenancePort.java`
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/`:
`ObjectPublishRequest.java`, `ObjectReadRequest.java`, `ObjectRetireRequest.java`,
`ObjectPurgeRequest.java`, `ObjectStageRequest.java`, `ObjectVerifyRequest.java`,
`ObjectScanReadRequest.java`, `ObjectScanVerdictRequest.java`, `ObjectFinalizeRequest.java`,
`ObjectAbortRequest.java`, `ObjectHandoffClaimRequest.java`,
`ObjectHandoffRenewRequest.java`, `ObjectHandoffReleaseRequest.java`,
`ObjectAbortAuthorization.java`, `DirectUploadGrantRequest.java`,
`DirectUploadCompletionRequest.java`, `DirectDownloadGrantRequest.java`,
`MultipartStartRequest.java`, `PartUploadGrantRequest.java`,
`MultipartPartAcknowledgement.java`, `MultipartCompleteRequest.java`,
`MultipartAbortRequest.java`
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/`:
`ObjectDescriptor.java`, `ObjectPublishReceipt.java`, `ObjectReadReceipt.java`,
`ObjectMutationReceipt.java`, `ObjectOperationResolution.java`,
`ObjectStageReceipt.java`, `ObjectVerificationReceipt.java`,
`ObjectHandoffReceipt.java`, `DirectUploadGrant.java`,
`DirectUploadCompletionReceipt.java`, `DirectDownloadGrant.java`,
`MultipartSession.java`, `PartUploadGrant.java`, `MultipartReceipt.java`,
`ObjectMutationOutcome.java`, `ObjectOperationError.java`,
`ObjectPublicationState.java`, `ObjectScanState.java`,
`ObjectPublicationRequirement.java`, `ObjectRetentionRequirement.java`,
`ObjectEncryptionRequirement.java`, `ObjectCapabilityRequirement.java`
- Test:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java`
- [x] **Step 1: Write the failing port-shape contract**
Use reflection and construction tests to prove:
- every mutation request contains `ObjectOperationKey`;
- inspect/transfer require opaque published references and cannot accept stage handles;
- purge is a distinct port from business retirement;
- direct completion verifies rather than trusting a client success flag;
- multipart completion accepts only server-issued part tokens;
- staged finalization is the only staged operation that returns an `ObjectReference`;
- scan verdict binds exact stage/version, scanner policy revision, and scan operation;
- `StagedObjectPublicationPort` has no scan-read/verdict methods;
`ObjectScanMaintenancePort` alone owns unpublished exact-version transfer and verdict recording,
and neither normal publication nor purge port is assignable to it;
- receipts expose no locator, ETag, upload ID, URL, credential, or provider enum;
- requirements can strengthen but never lower destination policy.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test \
--tests '*ObjectStoragePortContractTest' --console=plain
```
Expected: compilation failure because the semantic port family does not exist.
- [x] **Step 3: Implement the minimum framework-free API**
Follow design §9 exactly. Keep one public top-level type per file. Use immutable collections and
defensive copies where required. Grants contain a bounded URI, signed header names/values,
expiration, and opaque session identity, but their `toString` must redact the URI and headers.
`ObjectPurgeMaintenancePort` documentation must state its privileged composition boundary.
Document the separate scanner-workflow composition boundary on `ObjectScanMaintenancePort`; the
normal staged port owns only stage, integrity verification, finalize, and abort.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS.
### Task 5: Enforce application purity and isolate the legacy compatibility seam
**Files:**
- Modify:
`src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java`
- Modify:
`src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java`
- Create:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
- [x] **Step 1: Write failing purity tests**
Assert recursively through fields, methods, constructors, record components, generic arguments, and
annotations that `dev.caskeleton.application.objectstorage..` has no Spring, AWS SDK, servlet,
transport DTO, `Path`, `File`, persistent provider locator, JPA, SLF4J, or adapter type. Permit
`java.net.URI` only in the explicit direct-grant values and prove their redacted/persistence
boundary. Add an ArchUnit rule that new sample business code may not import
`dev.caskeleton.application.storage`; freeze the current upload use case until Task 23 splits it
into an explicitly named legacy-only package, and permit the later exact
`application.storage.migration` adoption use case as an admin-only compatibility exception.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test \
--tests '*ObjectStorageArchitectureContractTest' --console=plain
./gradlew :app-bootstrap:test \
--tests '*CleanArchitectureTest' --console=plain
```
Expected: the new sample-import rule initially identifies the current legacy consumer, or the test
fixture explicitly records it as the single frozen violation. No unrelated architecture violation
may be accepted.
- [x] **Step 3: Deprecate without adapting new calls back to raw keys**
Mark both legacy types `@Deprecated(forRemoval = true)` and document:
- legacy overwrite/materialization semantics;
- separate legacy namespace;
- production-disabled target state;
- no use from new code;
- removal gates, not an invented removal date.
Freeze the existing `UploadPosterImageUseCase` as the only temporary sample violation until Task 23.
Task 23 must move the remaining compatibility surface into an allowlisted `..poster.legacy..`
slice; no non-legacy sample package may import the old port after that cutover. Do not create a
semantic-to-legacy adapter that throws away operation identity or guarantees.
- [x] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS with exactly the named frozen legacy violation and zero
provider/framework leaks. The later migration exception must be bounded/redacted and visible only
to the named administrative use case.
- [x] **Step 5: Run Batch A checkpoint**
Run:
```bash
cd src
./gradlew :application-core:check --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
```
Expected: PASS. Update readiness documentation to R0 contract only. Do not call this an implemented
object-storage provider.
---
## Batch B — Phase 2: Provider-neutral kernel and local R1
### Task 6: Add canonical namespace, reference, fingerprint, and policy codecs
**Files:**
- Modify:
`src/adapter/outbound/objectstorage/build.gradle`
- Modify:
`src/adapter/outbound/objectstorage/gradle.lockfile`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/`:
`ObjectDataKeyCodec.java`, `ObjectControlKeyCodec.java`,
`ObjectReferenceCodec.java`, `ObjectHandleCodec.java`,
`ObjectRequestFingerprintCodec.java`, `ObjectPolicySnapshotCodec.java`,
`CrockfordBase32.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`:
`ObjectBindingRevision.java`, `ObjectPolicyRevision.java`, `ObjectRouteToken.java`,
`ObjectPolicySnapshot.java`, `ObjectOperationEpochRecord.java`,
`ObjectOperationEpochState.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodecTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java`
- [x] **Step 1: Add the approved test-only property engine**
Add `testImplementation 'net.jqwik:jqwik:1.9.1'` and update only this leaf's lockfile:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
```
Expected: PASS with no production jqwik dependency.
- [x] **Step 2: Write failing codec and epoch tests**
Use golden vectors and property tests for:
- `data/v1` and every `control/v1` grammar in design §10;
- ASCII-only segments, fixed maximum segment/total length, deterministic shard, and rejection of
slash aliases, percent encoding, Unicode normalization ambiguity, `.`/`..`, and control
characters;
- data-key APIs accept only generated `ObjectId`, route, generation, and typed revision values,
never a filename/tenant/raw-name `String`; compile/static contract tests prove that representative
email/filename values have no accepted parameter path instead of attempting a PII heuristic;
- the exact `osr1`/`osh1`/`osu1`/`osm1` grammar frozen above;
- canonical fingerprint field order, absence-versus-empty, integer overflow, enum names, and
schema version;
- same intent yielding the same fingerprint and any semantic field change yielding a different
fingerprint;
- binding/policy snapshots that contain no secret or provider credential;
- epoch `WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED` transitions, no token reuse, and
`OPERATION_EXPIRED` after seal/compaction.
- [x] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectNamespaceCodecTest' \
--tests '*ObjectRequestFingerprintCodecTest' \
--tests '*ObjectOperationEpochTest' --console=plain
```
Expected: compilation failure because the codec/kernel types do not exist.
- [x] **Step 4: Implement deterministic codecs**
Keep all physical key construction in these codecs. Route lookup uses retained binding revision,
never a current-provider default. The fingerprint includes exact content identity for R2 and an
explicit `R1_UNVERIFIED_CONTENT` marker for compatibility; it never hashes content by materializing
the object. Operation epoch records include finite replay/retention/compaction bounds.
- [x] **Step 5: Verify GREEN**
Run the command from Step 3 and
`./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain`. Expected: PASS.
### Task 7: Add strict control records and provider-neutral operation state machines
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/`:
`ObjectControlRecord.java`, `ObjectOperationRecord.java`,
`ObjectManifestRecord.java`, `ObjectReferencePointerRecord.java`,
`ObjectReferenceRecord.java`, `ObjectMultipartSessionRecord.java`,
`ObjectMultipartPartRecord.java`, `ObjectControlRecordEnvelope.java`,
`ObjectControlRecordCodec.java`, `CanonicalJsonObjectControlRecordCodec.java`,
`CanonicalJsonReader.java`, `CanonicalJsonWriter.java`,
`ObjectControlStore.java`, `ObjectControlVersion.java`,
`ObjectControlMutation.java`, `ObjectControlConflictException.java`,
`ObjectControlCorruptionException.java`, `UnsupportedObjectControlSchemaException.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`:
`PublicationOperationState.java`, `ScanOperationState.java`,
`PublishedReferenceState.java`, `DirectGrantSessionState.java`,
`MultipartUploadState.java`, `PendingObjectEffect.java`,
`ObjectEffectCertainty.java`, `ObjectOperationStateMachine.java`,
`ObjectOperationKernel.java`, `ObjectOperationKernelResult.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java`
- [x] **Step 1: Write failing strict-codec tests**
For every record family introduced through Task 7 prove:
- canonical byte-for-byte round trip and checked-in golden fixture;
- schema v1 only for writes;
- duplicate, unknown, missing, reordered-invalid, truncated, oversized, checksum-mismatched, and
newer-schema records fail closed rather than appearing absent;
- operation state and revision invariants;
- frozen route/provider/binding/policy/codec/checksum/encryption/retention revisions;
- separate expected/observed content digest and provider ETag/checksum evidence;
- no secret, URL, raw credential, original filename, public ACL, absolute path, or inbound data;
- independent part records, with no unbounded in-session map.
Also prove an immutable manifest revision binds `ObjectId`, exact provider version, logical
size/digest/media type, encryption/retention evidence, and immutable data version, while a separate
small reference pointer CASes only the current manifest revision. No mutable pointer is treated as
the manifest itself.
- [x] **Step 2: Write failing transition-table tests**
Cover every allowed and forbidden transition from design §12, including:
- reservation, pending effect before I/O, evidence-based certainty after I/O;
- terminal same-fingerprint replay without producer invocation;
- any-state different-fingerprint conflict;
- response loss yielding `INDETERMINATE` until resolution;
- scan, published-reference, direct-session, and multipart states remaining independent;
- stale fence/revision rejection;
- unknown/newer state never auto-deleted or downgraded.
- [x] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectControlRecordCodecTest' \
--tests '*ObjectOperationStateMachineTest' \
--tests '*ObjectOperationKernelTest' --console=plain
```
Expected: compilation failure because the control-plane and state-machine types do not exist.
- [x] **Step 4: Implement the minimum kernel**
The kernel accepts a compiled policy snapshot and an `ObjectControlStore`; it does not import a
provider SDK. Reserve writes the frozen snapshot before provider mutation. Every mutation writes a
pending-effect attempt before I/O and resolves from exact evidence after I/O. Implement
`canonical-json-v1` with the named bounded JDK-only reader/writer and an explicit closed family
discriminator; no reflective or `Map<String,Object>` binding and no undecided JSON dependency is
allowed. A later task that adds a durable family must modify this codec, add checked-in golden
bytes, and prove old/new reader compatibility before the selected write version changes.
- [x] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 8: Implement the bounded `filesystem-local-dev` provider
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/`:
`ObjectStorageProvider.java`, `ObjectStorageProviderDescriptor.java`,
`ObjectStorageProviderOperation.java`, `ObjectStorageProviderException.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/`:
`LocalDevObjectStorageProvider.java`, `LocalDevObjectControlStore.java`,
`LocalDevObjectDataStore.java`, `LocalObjectPathGuard.java`,
`LocalObjectStreamTransfer.java`
- Create:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/contract/ObjectStorageProviderContract.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProviderTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java`
- [x] **Step 1: Write the reusable provider contract**
The abstract suite must be executable for each provider and cover:
- empty, one-byte, chunk-minus-one, exact-chunk, chunk-plus-one, and maximum-size upload;
- bounded producer invocation and no full-object buffer;
- immutable create/conflict and terminal replay;
- exact inspect/version/digest;
- full and one contiguous range read;
- short/failing/stalled producer and slow/failing consumer;
- cancellation before and during transfer;
- checksum match/mismatch;
- conditional retirement;
- response-loss resolution;
- callback/resource invalidation and closure.
Unsupported optional capabilities must assert descriptor `UNSUPPORTED`, not skip.
- [x] **Step 2: Write failing local security/recovery tests**
Use `@TempDir` and injected filesystem/fault collaborators to test:
- traversal, absolute path, Unicode alias, root escape, symlink root/nested/swap;
- exclusive-create race with two writers;
- restrictive created permissions where POSIX exists;
- disk full, permission denied, read-only simulation, short write, truncated read;
- restart after each control/data step;
- same operation recovery without producer replay;
- corrupt/newer records quarantined, never absent/deleted;
- file descriptor and temporary-file cleanup.
- [x] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*LocalDevObjectStorageProviderTest' \
--tests '*LocalDevObjectStorageRecoveryTest' --console=plain
```
Expected: compilation failure because the local provider/kernel integration does not exist.
- [x] **Step 4: Implement bounded local R1**
Use adapter-generated immutable data names, `CREATE_NEW`, bounded chunks, streaming SHA-256, staged
temporary files, force/atomic move only where the host proves it, and strict relative path checks.
The local control store serializes per operation in one process. When portability or crash
durability cannot be proven, return the truthful R1 descriptor; never claim multi-node CAS or
power-loss durability. This provider is rejected in production profiles.
- [x] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 9: Compile exact settings and compose a disabled-by-default routing capability
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/`:
`ObjectStorageCapabilitySettings.java`, `ObjectStorageProviderSettings.java`,
`ObjectStorageDestinationSettings.java`, `CompiledObjectStorageProvider.java`,
`CompiledObjectStorageDestination.java`, `ObjectStorageBindingCompiler.java`,
`ObjectStorageProviderContribution.java`, `SelectedObjectStorageProviderFactory.java`,
`ObjectStorageCapabilityAssembler.java`, `ObjectStorageCapabilityConfig.java`,
`RoutingObjectReadAdapter.java`, `RoutingObjectMutationAdapter.java`,
`RoutingObjectDirectGrantAdapter.java`, `ObjectStorageMaintenanceCapabilityConfig.java`,
`LegacyObjectStorageActivationGuard.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompilerTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java`
- Modify:
`src/sample-portfolio/src/main/resources/application.yml`
- [x] **Step 1: Write failing binding tests**
Bind `app.object-storage` and reject:
- enabled without providers/destinations or required destination;
- missing/unknown provider ref/type or duplicate normalized IDs;
- any implicit provider, destination, bucket, namespace, root, or capability;
- invalid namespace/size/chunk/part/replay/timeout/retry/amplification bounds;
- destination requirement stronger than provider descriptor;
- route or namespace collision/reuse;
- unknown, retired, or unavailable retained route revision during read/reconcile;
- scan-required destination without the scan seam;
- local-dev selected in a production profile;
- canonical settings and any old `ca-skeleton.objectstorage.*` alias present together;
- legacy and canonical data/control namespaces overlapping.
Prove a valid local profile compiles one exact route and immutable policy snapshot.
- [x] **Step 2: Write failing composition tests**
Use `ApplicationContextRunner` to prove:
- absent or `enabled=false` creates zero ports, directory, credential lookup, client, thread,
scheduler, health indicator, and warning;
- enabled explicit local binding creates exactly one routing implementation for each applicable
semantic port;
- an unknown destination fails before producer invocation;
- routing retains old route/binding revisions for reads/reconciliation;
- `matchIfMissing` is gone;
- legacy-only mode remains isolated and opt-in during migration;
- an old `ca-skeleton.objectstorage.*` alias together with any canonical setting fails without
logging property values;
- a namespace-separated canonical `legacy` subgroup plus the new capability may run together only
in the explicit migration/sample-local profile.
- [x] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageBindingCompilerTest' \
--tests '*ObjectStorageCapabilityConfigTest' --console=plain
```
Expected: compilation/test failure because canonical settings/composition do not exist and current
legacy configuration activates filesystem by default.
- [x] **Step 4: Implement exact binding and activation**
Use immutable constructor-bound settings and typed `Duration`/`DataSize`/enums. Compile settings
before constructing any provider. Contributions are side-effect-free descriptors; the assembler
invokes only the exact selected contribution after successful compilation, so an unselected
provider cannot resolve credentials, construct a client, create a thread, or touch a directory.
Normal read/mutation/direct facades are separate types; scan maintenance and privileged purge are
not implemented by or registered through a normal facade. The routers use immutable maps keyed by
destination and retained route revision and never fall back. Keep legacy configuration behind an explicit
`app.object-storage.legacy.enabled=true` compatibility condition, default false. Old
`ca-skeleton.objectstorage.*` aliases may activate legacy-only mode during the first migration
step, but their presence together with any canonical setting fails. A dual-run profile uses only
the canonical `legacy` subgroup with an explicit backend/root-or-prefix isolated from all v1
data/control namespaces.
Until Task 22 migrates the consumer, the sample-local YAML explicitly enables the isolated legacy
filesystem seam and may also select `filesystem-local-dev` at `./.data/object-storage-v1` for the
new capability. No production YAML receives a local fallback.
- [x] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
- [x] **Step 6: Prove the stable contribution seam**
Add an `ApplicationContextRunner` matrix with a counting fake contribution: disabled, unselected,
invalid binding, selected success, selected construction failure, and close. Prove construction
occurs exactly once only after compilation, close occurs exactly once, and there is no privileged
maintenance/purge bean in the normal context. Every later provider task must extend this matrix
when it registers a contribution.
Re-run the Step 3 command. Expected: PASS; the contribution matrix is part of Task 9 GREEN, not an
unverified post-GREEN addition.
### Task 10: Publish the truthful local R1 card and close the Phase 2 gate
**Files:**
- Create:
`docs/registries/object-storage-readiness.yaml`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessLevel.java`
- Create:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java`
- Modify:
`src/adapter/outbound/objectstorage/README.md`
- Modify:
`src/adapter/outbound/objectstorage/CLAUDE.md`
- Modify:
`docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md`
- [x] **Step 1: Write the failing registry/schema test**
Validate the frozen schema and exact nine card IDs. Reject:
- unknown card/level/provider;
- a global “objectstorage R2” row;
- R1/R2 without exact provider version and destination profile;
- R2 without evidence revision/expiry and non-skipping required tasks;
- a required task that is absent from Gradle;
- local-dev above R1;
- a limitation-free row when a provider descriptor reports limitations.
- [x] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
```
Expected: failure because the registry and runtime card types do not exist.
- [x] **Step 3: Add only evidenced claims**
Initial entries may claim R0 for contract-only cards and R1 for local managed single upload/download
only after Tasks 69 pass. Direct, multipart, quarantine, retention, and production reconciliation
remain R0/unimplemented. Document that local process recovery is not multi-node CAS or R2.
- [x] **Step 4: Run Batch B checkpoint**
Run:
```bash
cd src
./gradlew \
:application-core:check \
:adapter:outbound:objectstorage:check --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
```
Expected: PASS.
- [x] **Step 5: Verify rollback**
Start a context with the canonical capability disabled and legacy disabled. Assert there are no
storage beans or side effects. Start the explicit legacy profile and prove old data remains
readable. No migration or deletion occurs at startup.
---
## Batch C — Phase 3: Managed S3/MinIO common subset
### Task 11: Characterize the pinned SDK and add the explicit async HTTP client
**Files:**
- Modify:
`src/adapter/outbound/objectstorage/build.gradle`
- Modify:
`src/adapter/outbound/objectstorage/gradle.lockfile`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`:
`S3AsyncClientFactory.java`, `S3ClientPolicy.java`, `S3ClientLifecycle.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3SdkApiCharacterizationTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java`
- [x] **Step 1: Write the failing pinned-API characterization**
Compile and assert the exact `2.30.0` API used by later tasks:
- `PutObjectRequest.Builder.ifNoneMatch` and `ifMatch`;
- `CompleteMultipartUploadRequest.Builder.ifNoneMatch`, `ifMatch`, and
`mpuObjectSize(Integer)`;
- explicit request/response checksum configuration;
- `expectedBucketOwner` on every relevant request;
- presigner availability without constructing it yet.
Add boundary tests at `Integer.MAX_VALUE`, `Integer.MAX_VALUE + 1L`, part count 10,000/10,001, and
minimum S3 non-final part size. Record the full-object multipart checksum profile as unsupported
above the SDK integer boundary unless an independently approved path exists.
- [x] **Step 2: Write failing client-policy tests**
Reject missing/non-positive/contradictory:
- parent API call and per-attempt timeout;
- connect, TLS negotiation, acquire, read, and write timeout;
- max concurrency and pending acquire bounds;
- SDK retry attempts/backoff that exceed the parent budget;
- shutdown grace;
- plaintext AWS endpoint, endpoint userinfo/query/fragment, and partial static credentials.
Assert the factory uses `S3AsyncClient` plus explicit `NettyNioAsyncHttpClient`, not CRT or the sync
client, and owns close order.
- [x] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3SdkApiCharacterizationTest' \
--tests '*S3AsyncClientFactoryTest' --console=plain
```
Expected: compilation failure because the client factory and compile-scoped Netty async client do
not exist.
- [x] **Step 4: Add only the required dependency and implementation**
Add `software.amazon.awssdk:netty-nio-client` under the existing AWS BOM. Keep
`software.amazon.awssdk:s3` and the BOM at `2.30.0`; do not add CRT or Transfer Manager. Configure
finite client and HTTP timeouts/pools/retry from the compiled policy. Default-chain credentials are
resolved only after the provider is selected.
- [x] **Step 5: Regenerate and verify locks**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3SdkApiCharacterizationTest' \
--tests '*S3AsyncClientFactoryTest' --console=plain
```
Expected: PASS, with only reviewed async-client transitive changes in the module lockfile.
### Task 12: Compile exact AWS and MinIO provider bindings and qualification descriptors
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`:
`S3ProviderSettings.java`, `S3ProviderBinding.java`, `S3ProviderType.java`,
`S3ProviderVersion.java`, `S3ProviderErrorMapper.java`,
`S3CapabilityProbe.java`, `S3ProviderQualifier.java`,
`S3QualificationEvidence.java`, `S3ObjectStorageProviderContribution.java`,
`S3ProviderCapabilityConfig.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/`:
`ObjectStorageCapabilityDescriptor.java`, `CapabilityEvidence.java`,
`CapabilityEvidenceStatus.java`, `CapabilityEvidenceSource.java`,
`ObjectStorageOperationProfile.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderSettings.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBindingTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifierTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapperTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- [ ] **Step 1: Write failing exact-binding tests**
Accept only:
- `aws-s3-general-purpose`;
- `s3-compatible-minio-community-release-2024-01-16t16-07-38z`.
Reject `s3`, `s3-compatible`, unknown version, directory bucket/S3 Express, MRAP/access point,
auto-create, public ACL, plaintext production endpoint, missing AWS expected owner, missing MinIO
deployment identity, namespace collision, static production credentials, unbounded budgets, and a
destination whose exact named profile has no unexpired `SUPPORTED` evidence.
Do not derive one combined profile by AND-ing unrelated booleans.
Use the Task 9 contribution seam to prove disabled, unselected, and invalid bindings create no
client, credential lookup, DNS, executor, or probe; only a selected, fully compiled exact provider
constructs one lifecycle-owned client and closes it exactly once.
- [ ] **Step 2: Write failing qualifier/error tests**
Prove normalized mapping for permission, owner/region mismatch, `404`, `409`, `412`, throttling,
timeout, checksum mismatch, retention/hold, and unknown response loss. A final SDK exception alone
must not turn a mutation into an authoritative failure. Safe probe mode may access only its reserved
prefix and may not create a bucket or change versioning/lifecycle/CORS/ownership/BPA/encryption.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3ProviderBindingTest' \
--tests '*S3ProviderQualifierTest' \
--tests '*S3ProviderErrorMapperTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation failure because exact S3 provider models and qualification do not exist.
- [ ] **Step 4: Implement the minimum descriptors**
Descriptor axes and named profiles follow design §21. Qualification sources are
`STATIC_ATTESTATION`, `STARTUP_PROBE`, or `CI_QUALIFICATION`, each with digest, provider/deployment
identity, observation/expiry, and limitations. `UNVERIFIABLE` and expired evidence never compile as
supported. Keep AWS/MinIO behavior separate behind the same provider-neutral kernel. Register the
side-effect-free S3 contribution explicitly; do not use component scanning as activation.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 13: Implement bounded managed single upload, inspect, download, and range
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`:
`S3ManagedObjectProvider.java`, `S3AsyncRequestBodyBridge.java`,
`S3AsyncResponseBodyBridge.java`, `S3ObjectEvidenceMapper.java`,
`S3ChecksumPolicy.java`, `S3ConditionalRequestMapper.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridgeTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridgeTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProviderTest.java`
- [ ] **Step 1: Write failing bridge tests**
With deterministic executors and a fake async subscriber/publisher, prove:
- aggregate buffers never exceed configured chunks/bytes;
- producer/consumer runs off the SDK event-loop;
- backpressure prevents unbounded producer lead;
- single-pass producer is invoked once;
- cancellation, callback failure, subscriber cancellation, short/zero-progress/truncated body, and
deadline expiry close resources and release admission;
- logical SHA-256 is computed while streaming and checked independently of provider checksum;
- no whole-object `byte[]`, `toBytes`, `getObjectAsBytes`, or `RequestBody.fromBytes` path exists.
- [ ] **Step 2: Write failing provider tests**
Mock only the SDK boundary and prove exact request mapping:
- immutable single PUT uses `If-None-Match: *`, exact content length/checksum/encryption/owner;
- HEAD validates size, version, checksum, encryption, and publication record;
- GET/range validates returned version, range, content length/range, and digest mode;
- empty object is valid;
- `409`/`412`, permission, absence, throttling, and response loss map to distinct outcomes;
- public receipts contain no S3 locator/evidence.
- the selected S3 contribution exposes managed single/inspect/download/range delegates through the
normal routers, while unselected/disabled contexts still expose none and create no resources.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3AsyncRequestBodyBridgeTest' \
--tests '*S3AsyncResponseBodyBridgeTest' \
--tests '*S3ManagedObjectProviderTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation failure because the async bridges/provider do not exist.
- [ ] **Step 4: Implement the minimum managed path**
Bridge the synchronous application callbacks through a bounded adapter-owned worker and queue.
Propagate cancellation to the SDK future/body, invalidate callback resources, and release every
semaphore/buffer. Do not let SDK retry replay a non-repeatable producer. If transport retry requires
body replay, resolve evidence or require a new operation; an adapter spool needs a separate plan.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 14: Implement S3 conditional control storage and response-loss resolution
**Files:**
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java`
- [ ] **Step 1: Write failing CAS tests**
Prove exact-key direct lookup and:
- reserve with `If-None-Match: *`;
- update with the record's exact private ETag in `If-Match`;
- stale writer and same-operation/different-fingerprint conflict;
- dropped create/update response reconciled by GET and record digest/revision comparison;
- `404` is authoritative only after the operation-specific evidence rules permit it;
- corrupt/newer record never becomes absent or overwritten;
- LIST is not used on the request path.
- [ ] **Step 2: Write the failing response-loss matrix**
Inject loss after operation reserve, data PUT, data HEAD, reference create, terminal record CAS, and
retirement. Expected outcomes must be terminal replay, deterministic continuation, typed conflict,
or `INDETERMINATE`; never blind duplicate mutation or producer replay.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3ConditionalObjectControlStoreTest' \
--tests '*S3ObjectOperationResolverTest' \
--tests '*ObjectMutationResponseLossTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation/failing reconciliation because S3 CAS/resolution does not exist.
- [ ] **Step 4: Implement conditional storage and resolution**
Persist pending effect, attempt ID, exact precondition, and request-evidence digest before each
mutation. Use frozen binding/policy revision for resolution. Provider ETag remains private and is
not treated as logical content digest or public version. Unsupported conditional semantics fail
provider qualification; do not emulate with HEAD-then-unconditional-PUT. Wire the conditional store
and resolver into only the selected S3 contribution and retain the disabled/unselected zero-effect
composition assertions.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 15: Implement adapter-owned managed multipart and a sharded part ledger
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/`:
`ManagedMultipartCoordinator.java`, `MultipartUploadPlan.java`,
`MultipartPartLedger.java`, `MultipartCompletionEvidence.java`,
`MultipartOperationResolver.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProvider.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinatorTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProviderTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartResponseLossTest.java`
- [ ] **Step 1: Write failing plan/ledger tests**
Prove:
- single PUT versus multipart threshold is deterministic and frozen;
- S3 part minimum, final-part exception, max 10,000 parts, per-part/in-flight/concurrency budgets;
- each completed part is an immutable bounded control record;
- duplicate same part evidence replays; different evidence conflicts;
- ordered completion derives only from the server ledger;
- full logical SHA-256 remains separate from part/provider checksums;
- SDK `mpuObjectSize(Integer)` overflow rejects the incompatible checksum profile before I/O.
- [ ] **Step 2: Write failing provider/recovery tests**
Cover create, upload part, list parts, complete, abort, `404`/`409`/`412`, stale upload ID, dropped
part response, dropped complete response, concurrent abort/complete, process restart, and orphan
candidate production. Completion after a `409` that requires a new upload must not retry the old
upload ID. Before `CreateMultipartUpload`, require an `INITIATE_IN_PROGRESS` control CAS with a
pending effect, deterministic operation-exclusive data key, and attempt evidence. Inject the fault
where S3 creates the upload ID but the response is lost: bounded, paginated discovery by that exact
key/attempt horizon may adopt one unambiguous upload; zero/multiple/unprovable candidates remain
`INDETERMINATE` orphan evidence and must not trigger a blind second initiate.
Prove create-only completion uses `If-None-Match: *` on
`CompleteMultipartUpload`, with distinct `404`/`409`/`412` outcomes. SSE-KMS/DSSE and Object Lock
headers belong on `CreateMultipartUpload`; `UploadPart`/complete receive only operation-appropriate
checksum/owner/precondition fields, not copied PUT-only KMS headers. An Object-Lock request includes
the provider-required `Content-MD5` or an exact qualified checksum.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ManagedMultipartCoordinatorTest' \
--tests '*S3ManagedMultipartProviderTest' \
--tests '*MultipartResponseLossTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation failure because managed multipart types do not exist.
- [ ] **Step 4: Implement low-level multipart only**
Call `CreateMultipartUpload`, `UploadPart`, `ListParts`, `CompleteMultipartUpload`, and
`AbortMultipartUpload` directly. Never delegate R2 state to SDK automatic multipart. Keep upload ID
and part ETags private. Do not send create until `INITIATE_IN_PROGRESS` is durable, and do not
re-initiate while its outcome is uncertain. Register the managed multipart delegate and its closed
record-family codec explicitly in the selected S3 contribution; add golden/old-reader fixtures for
every new durable field. Abort eligibility is recorded but physical cleanup remains report-only
until Task 27; destructive provider qualification is Task 29 and requires explicit authorization.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 16: Qualify the pinned MinIO managed subset and prepare the protected AWS lane
**Files:**
- Modify:
`src/adapter/outbound/objectstorage/build.gradle`
- Modify:
`src/adapter/outbound/objectstorage/gradle.lockfile`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/test/resources/object-storage/minio-provider-evidence.json`
- Create:
`.github/workflows/object-storage-qualification.yml`
- Modify:
`.github/ci-gate-matrix.yml`
- Modify:
`.github/scripts/verify-gate-matrix.sh`
- Modify:
`docs/registries/object-storage-readiness.yaml`
- [ ] **Step 1: Add non-skipping Gradle lanes**
Register:
```text
objectStorageMinioContractTest
objectStorageMinioFaultTest
objectStorageAwsQualificationTest
```
The first two require Docker and fail with an actionable prerequisite message when unavailable.
The AWS task requires explicit sandbox enablement, account/region/bucket/owner inputs, and fails
when selected inputs are absent. Keep the existing developer-fast `disabledWithoutDocker` legacy
test, but never cite it as readiness evidence.
Regenerate/review the leaf lock after adding the resolvable source-set configurations:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
```
Add a secret-free PR/container MinIO contract job and a scheduled/manual MinIO fault job to the
gate matrix. The protected AWS job is declared but cannot execute or emit evidence until Approval
Gate B supplies authority. Artifacts contain normalized results/image digests only—never generated
credentials, endpoints, account IDs, or signed requests.
- [ ] **Step 2: Write the failing MinIO contract/fault tests**
Use the exact MinIO release frozen above, pinned by image digest before GREEN. Use
`ghcr.io/shopify/toxiproxy:2.12.0` pinned by digest for TCP latency/reset/bandwidth faults. Generate
test credentials at runtime rather than keeping known literals in source.
Run the shared provider suite plus:
- actual conditional create/CAS;
- checksum/HEAD/range;
- managed multipart/abort/list/complete;
- response drop after data/control mutation;
- connection cut, slow body, process restart;
- concurrent same/different fingerprint;
- bounded heap/direct memory/thread/FD assertions.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
--console=plain
```
Expected: failure until images are digest-pinned, the shared suite is wired, and all required
semantics pass. Docker absence is a failure, not success/skip.
- [ ] **Step 4: Implement only missing provider semantics**
Do not weaken the contract for MinIO. If the exact release cannot prove a conditional, checksum,
multipart, or recovery behavior, record that operation profile as `UNSUPPORTED`/`UNVERIFIABLE` and
keep the corresponding binding/card disabled.
- [ ] **Step 5: Re-run the required MinIO lanes GREEN**
Run the command from Step 3. Expected: PASS for every advertised exact profile, with zero selected
test skips. Any unsupported profile is absent from the selected contract matrix and is asserted as
`UNSUPPORTED` by a separate test; it is not hidden by a skip.
- [ ] **Step 6: Compile, but do not execute, the protected AWS common-subset lane**
Compile its source set without contacting AWS:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
bash ../.github/scripts/verify-gate-matrix.sh
```
Expected: PASS with no AWS evidence row. If a registry placeholder is necessary, keep it at R0
with `limitation: authority_pending` and no observed-evidence fields; do not invent a new evidence
status. Task 29, after Approval Gate B, executes this test with security/fault qualification. Plan
approval alone is not authority to mutate an external bucket.
- [ ] **Step 7: Run Batch C checkpoint**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
```
Expected: PASS. Publish at most exact MinIO R1 managed cards; publish no AWS claim or observed
evidence yet. Preserve old route/binding readers before enabling any new destination writer.
---
## Batch D — Phase 4: Direct transfer and multipart
This batch implements provider/application primitives and qualification surfaces only. A public
signing/direct endpoint remains out of scope and requires a separate follow-up plan.
### Task 17: Implement presigned single-upload and exact-version download grants
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/`:
`DirectTransferSessionRecord.java`, `DirectTransferPolicy.java`,
`DirectGrantGeneration.java`, `DirectTransferCoordinator.java`,
`PresignedGrantRedactor.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`:
`S3PresignerFactory.java`, `S3DirectTransferProvider.java`,
`S3DirectCompletionVerifier.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinatorTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProviderTest.java`
- [ ] **Step 1: Write failing grant-policy tests**
Reject:
- non-HTTPS public presign endpoint outside explicit local-test profile;
- userinfo/query/fragment in configured endpoint or host outside allowlist;
- expiry zero/negative/over maximum, beyond the attested `s3:signatureAge` bound, or at/after
`credential/session horizon - qualified maximum clock skew`;
- missing exact operation/content identity, checksum, media type, encryption/retention header, or
create-only precondition required by the named profile;
- a named `direct-single-hard-ceiling` profile when the destination cannot prove a
provider-enforced hard size ceiling;
- an unhealthy/unqualified local clock or NTP status before any new grant;
- direct download before application authorization or for unpublished/retired/wrong-version data.
- [ ] **Step 2: Write failing lifecycle and redaction tests**
Prove the exact grant linearization:
```text
session revision CAS -> GRANT_PREPARED(
constraintsDigest, signingTime, expiresAt, credentialRevision, referenceRevision)
sign exact request
same-revision CAS -> GRANT_ISSUED
return bearer URI
```
The `GRANT_PREPARED` CAS must precede signing; `GRANT_ISSUED` is preconditioned on that exact
generation/reference revision and must precede response. Also prove:
- a lost grant response may reissue only under the frozen generation policy;
- multiple outstanding generations are bounded and tracked through expiry plus in-flight horizon;
- completion ignores a client “success” boolean and performs exact HEAD/checksum/size/version/
encryption verification;
- retirement CAS linearizes before issuing a download grant;
- retirement winning before the issued CAS discards the signed URL and returns no grant; issued CAS
winning first means the already-issued URL truthfully remains valid until expiry and is not
relabeled “not issued” even if its response is lost;
- URI, query signature, signed header values, credential scope, bucket/key, and session internals
never appear in `toString`, logs, traces, exceptions, metrics, or control records.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectTransferCoordinatorTest' \
--tests '*PresignedGrantRedactionTest' \
--tests '*S3DirectTransferProviderTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation failure because direct coordination/provider types do not exist.
- [ ] **Step 4: Implement the minimum direct single path**
Use `S3Presigner` owned by the provider lifecycle. Sign only an exact method, key, checksum/content
headers, encryption/retention headers, and bounded expiry required by the compiled profile. Treat
the URL as a bearer secret. Persist the new direct-session family through the closed control codec
with golden/old-reader fixtures, and wire the direct delegate only through the selected S3
contribution. Completion creates a stage receipt only after evidence verification; publication
remains a separate state transition.
Keep two explicit profiles rather than one contradictory rule:
`direct-single-hard-ceiling` is rejected unless the provider enforces the ceiling;
`direct-single-soft-limit-r1` may be enabled only with a documented maximum exposure, immediate
post-upload verification/quarantine, and an R1 ceiling. Do not claim that a post-upload HEAD
prevents temporary oversized storage. POST policy remains unsupported until a separately audited
signer is added. New grant admission fails closed when qualified clock health is unavailable.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 18: Implement direct multipart grants, acknowledgement, completion, and abort
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/`:
`DirectMultipartCoordinator.java`, `DirectMultipartGrantLedger.java`,
`DirectPartAcknowledgementVerifier.java`, `DirectMultipartCompletionVerifier.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartRaceTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProviderTest.java`
- [ ] **Step 1: Write failing session/part tests**
Prove:
- start first CASes `INITIATE_IN_PROGRESS` plus exact pending-effect evidence, then calls create;
provider-accepted/create-response-lost recovery uses bounded, paginated, exact operation-key
discovery and never blindly initiates again; ambiguity remains `INDETERMINATE`/orphan;
- a confirmed start persists the provider upload ID privately before returning a session;
- part grants are bounded by part number/count/size/concurrency/expiry and server generation;
- acknowledgement accepts only an allowlisted, bounded provider completion claim and converts it to
an opaque `PartReceiptToken`;
- a reissued part grant cannot let a stale late request silently replace an acknowledged part;
- completion closes grant/ack admission, waits for every issued generation expiry plus qualified
clock skew and maximum in-flight horizon (or proves controlled-ingress drain), then paginates
`ListParts`;
- completion compares each current ledger revision/token against exact provider part number,
private ETag, checksum algorithm/type/scope, and length before a `COMPLETE_IN_PROGRESS` CAS;
- conditional complete is followed by exact final version/size and `FULL_OBJECT` SHA-256
verification; multipart ETag or composite checksum is never treated as the logical full digest;
- incomplete, duplicate-conflicting, stale, or over-budget ledgers fail before provider complete;
- provider upload ID and part ETag/checksum never cross the application contract.
- [ ] **Step 2: Write failing race/response-loss tests**
Cover:
- initiate accepted followed by lost response and ambiguous orphan discovery;
- grant response loss/reissue;
- part response loss and acknowledgement replay;
- acknowledge versus complete;
- late part request versus complete/abort;
- complete response loss and exact HEAD/ListParts resolution;
- `409` complete semantics;
- abort response loss and orphan discovery;
- process restart with active sessions;
- expiry plus maximum in-flight horizon before cleanup eligibility.
- retirement/abort racing the admission-close and `COMPLETE_IN_PROGRESS` CAS.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectMultipartCoordinatorTest' \
--tests '*DirectMultipartRaceTest' \
--tests '*S3DirectMultipartProviderTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation failure because direct multipart coordination does not exist.
- [ ] **Step 4: Implement the minimum direct multipart path**
Reuse the sharded control ledger, but keep managed and direct states distinct. Presign one exact
part per grant. Never accept client-supplied ETag/upload ID directly at completion. Abort requires
session state/fence and produces a pending effect; a timeout remains indeterminate until resolved.
For an R2-capable primitive, obtain the full logical SHA-256 by a bounded exact-version verification
read when provider evidence is only composite; do not publish before that read passes. Register the
direct multipart delegate and every new durable state/field explicitly in the selected S3
contribution and closed codec with golden compatibility fixtures.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 19: Qualify direct security/fault behavior without exposing an endpoint
**Files:**
- Modify:
`src/adapter/outbound/objectstorage/build.gradle`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java`
- Modify:
`docs/registries/object-storage-readiness.yaml`
- [ ] **Step 1: Write failing real-provider tests**
For the exact provider/version test:
- method, host, path, signed headers, checksum, expiry, create-only, and content constraints;
- clock-skew boundary, unhealthy-clock admission failure, and expiry below both credential horizon
and the exact attested signature-age ceiling;
- browser-visible CORS request headers and exposed completion headers;
- URL expiration and the explicit limitation that revocation is not immediate;
- direct completion verification;
- direct multipart acknowledgement/ListParts/complete/abort;
- direct multipart grant/ack close, late-request horizon, conditional complete, and exact-version
full-object digest verification (never ETag/composite substitution);
- response loss, Toxiproxy cut, concurrent replay, process restart, and orphan eligibility;
- log/trace/metric capture with zero URL/query/provider locator leakage.
- [ ] **Step 2: Verify RED in the required MinIO lanes**
Run:
```bash
cd src
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
--tests '*DirectTransfer*' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectTransferCorsContractTest' --console=plain
```
Expected: failures until every advertised direct profile and the normal-source-set browser CORS
contract pass. Docker absence remains a failure.
- [ ] **Step 3: Implement only missing qualified direct semantics**
Fix provider mapping, session reconciliation, CORS evidence, and redaction needed by the advertised
profiles. Do not weaken hard-size/create-only/checksum/expiry requirements. Mark a behavior
`UNSUPPORTED` when the exact topology cannot prove it.
- [ ] **Step 4: Re-run the required MinIO lanes GREEN**
Run both commands from Step 2. Expected: PASS for every advertised direct profile with zero
selected skips and for the normal-source-set CORS contract; unsupported profiles have explicit
negative descriptor tests.
- [ ] **Step 5: Compile, but do not execute, the AWS direct qualification lane**
Approval Gate B has not yet granted external mutation authority. Compile only:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
```
Expected: PASS with no observed AWS direct evidence. If represented in the registry, use R0 plus
`limitation: authority_pending`, not a new evidence status. Task 29 executes it after Gate B. No
AWS/direct R2 claim is possible in this plan because no public direct API is implemented.
- [ ] **Step 6: Publish only truthful Batch D state**
The registry may record R1 for exact functional profiles. Any hard-size, create-only, checksum,
CORS, or late-request behavior that is not proven remains `UNSUPPORTED`/`UNVERIFIABLE`. No row or
documentation implies that a public endpoint exists, and all direct cards remain at most R1/partial.
- [ ] **Step 7: Verify rollback**
Disable new grant admission, retain the session resolver, let issued grants expire through their
in-flight horizon, and reconcile/abort without deleting session records early. Managed upload and
published download remain available.
- [ ] **Step 8: Run Batch D checkpoint**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
bash ../.github/scripts/verify-gate-matrix.sh
```
Expected: PASS after the required MinIO direct lanes have passed with zero selected skips. Re-run
`ObjectStorageReadinessRegistryTest` as part of `check`; record both AWS managed/direct lanes as
authority-pending, not as passed evidence. Perform the mandatory Batch D status/Wiki capture before
Approval Gate A.
---
## Approval Gate A — Scanner ownership and sample public contract
**Approved 2026-07-29:** The user authorized continued implementation using the recommended
defaults. The deep design now records the exact decisions: test-fake scanner only,
`integrity-verified-reference`, additive POST/GET with stable `202`, Base64 SHA-256 multipart
field, required keyed idempotency with atomic TX1 reservation/intent, existing `poster:write`,
legacy compatibility profile, new V2 event without v1 removal, intentionally scoped OpenAPI
snapshot, and additive forward-only V8 dual read.
Do not execute Tasks 2024 until the approved design records all of the following:
- staged scan fencing may be implemented with a test fake, but no production scanner/provider is
claimed;
- the sample's first migrated profile is either `integrity-verified-reference` or an explicitly
named scan-gated profile with a real scanner owner;
- the additive endpoint proposal
`POST /posters/{id}/imagePublications`, status, authorization, request size/media policy, and
locator-free response fields;
- the asynchronous response contract: POST always returns the same bounded `202` reservation
(opaque publication operation plus status link) replayed by `IdempotencyExecutor`; a separately
authorized GET status resource reports progress and only exposes the published reference at
READY. The POST never sometimes returns a final payload under the same idempotency record;
- whether exact file SHA-256 is supplied as a bounded multipart field or a newly registered HTTP
header, including its canonical encoding; it is required before TX1, validated again while
staging, and multipart boundary/order is never part of the semantic fingerprint;
- required `Idempotency-Key`, fingerprint scope, and the rule that the first committed
`UploadIntent` allocates/reuses the stable `ObjectOperationId`;
- the exact `IdempotencyExecutor` shape: inside one TX1 it atomically claims/completes the stable
`202` reservation and creates-or-reads the durable intent keyed by versioned HMAC scope digest;
the same HTTP invocation then consumes the request-bound producer outside TX through stage/verify
and TX2 PENDING before returning that reservation; finalize/READY is asynchronous operation-keyed
continuation, and generic replay never allocates a second operation;
- the current sample remains permission-based (`poster:write`) because Poster has no owner
attribute; do not invent per-resource ownership checks without a separate domain/schema design;
- the explicit profile/release gate for the legacy `/posters/{id}/image` endpoint;
- a consumer inventory and owner approval for the existing `poster.image-attached` broker event;
choose a new versioned event type/envelope (preferred) or an explicit bounded dual-publish
window, consumer migration evidence, rollback, and zero-consumer proof before v1 removal. Never
rename `imageKey` to `reference` under the same unversioned event contract;
- intentional OpenAPI snapshot approval;
- the additive V8 schema and forward-only rollback window.
- the compatibility model during V8: existing legacy attachments remain readable/publishable,
legacy writes are restricted to the compatibility controller/profile, and the canonical profile
writes only READY opaque references.
If these decisions change the deep design, amend and re-review the design first. Planning this gate
does not constitute API approval.
## Batch E — Phase 5: Staged publication and sample migration
### Task 20: Implement staged integrity/scan/publication and application handoff fencing
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`:
`StagedObjectPublicationKernel.java`, `ObjectIntegrityVerificationService.java`,
`ObjectScanVerdictPolicy.java`, `ObjectReferencePublicationService.java`,
`ObjectPublicationHandoffService.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java`
- [ ] **Step 1: Write failing staged-state tests**
Prove:
- stage returns only an `ObjectStageHandle`;
- public inspect/read/download grant reject staged/quarantined objects;
- integrity mismatch never becomes verified or published;
- a scan-required destination cannot finalize before `CLEAN`;
- `MALICIOUS` and `INDETERMINATE` fail closed;
- verdict binds exact stage, object version, scan operation, scanner policy/version, and record
revision;
- duplicate same verdict replays and stale/different verdict conflicts;
- finalize is the first operation to create an opaque published reference.
- finalize first appends an immutable manifest revision binding `ObjectId` and exact immutable
provider version/evidence, then conditionally creates/CASes the small reference-current pointer;
response loss resolves both exact records and never rebuilds from current binding defaults.
- [ ] **Step 2: Write failing handoff/abort race tests**
Cover:
- claim, renew, release with monotonically increasing fence;
- stale worker cannot mark PENDING/READY or release a newer claim;
- active claim blocks abort;
- claim expiry alone does not authorize destructive abort;
- application intent first CASes `ABORT_AUTHORIZED` and then issues an exact
`ObjectAbortAuthorization`;
- late finalize versus abort authorization;
- missing application intent never triggers auto-delete;
- object without matching intent is quarantined/reported.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*StagedObjectPublicationKernelTest' \
--tests '*ObjectScanVerdictFenceTest' \
--tests '*ObjectPublicationHandoffRaceTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*ObjectStorageCapabilityConfigTest' \
--tests '*ObjectStorageScanMaintenanceConfigTest' --console=plain
```
Expected: compilation failure because staged/handoff implementations do not exist.
- [ ] **Step 4: Implement the minimum staged kernel**
Use the existing semantic ports from Task 4. Integrity verification may stream the unpublished
exact version through the narrow maintenance/scan read path. Scanner policy remains an input
verdict seam, not an objectstorage-owned malware engine. The handoff service never imports
persistence/sample types and accepts only application-provided claim/authorization contracts.
Register the handoff durable family in the closed codec with golden/old-reader fixtures and expose
publication only through the selected normal mutation facade. Scan exact-version read remains a
separate `ObjectScanMaintenancePort` facade/config with explicit scanner-workflow activation; no
scan or privileged purge port is registered in the normal context. Extend the
disabled/unselected/selected `ApplicationContextRunner` matrix and prove the normal facade cannot be
cast or injected as either privileged type.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS. This is R1 protocol evidence only; without a production
scanner, `object-storage-quarantine-publication` remains below R2.
### Task 21: Add the forward-only Poster image intent and dual-read schema
**Files:**
- Modify:
`src/sample-portfolio/build.gradle`
- Modify:
`src/sample-portfolio/gradle.lockfile`
- Create:
`src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`:
`PosterImageUploadIntent.java`, `PosterImageUploadIntentState.java`,
`PosterImageUploadIntentStorePort.java`, `PosterImageUploadIntentClaim.java`,
`PosterImageUploadIntentConflictException.java`, `PosterImageRetirementIntent.java`,
`PosterImageRetirementIntentState.java`, `PosterImageRetirementIntentStorePort.java`,
`PosterImageIdempotencyScopeDigest.java`, `PosterImageIdempotencyScopeDigesterPort.java`,
`PosterImageIdempotencyKeyEpochPort.java`,
`PosterImageSanitizedIdempotencyContextFactory.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/`:
`PosterImageAttachment.java`, `LegacyPosterImageAttachment.java`,
`PublishedPosterImageAttachment.java`, `PosterImageReference.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/`:
`PosterImageUploadIntentEntity.java`, `PosterImageRetirementIntentEntity.java`,
`PosterImageIdempotencyKeyEpochEntity.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/`:
`PosterImageUploadIntentJpaRepository.java`,
`PosterImageUploadIntentRepositoryAdapter.java`,
`PosterImageRetirementIntentJpaRepository.java`,
`PosterImageRetirementIntentRepositoryAdapter.java`,
`PosterImageIdempotencyKeyEpochJpaRepository.java`,
`PosterImageIdempotencyKeyEpochRepositoryAdapter.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`:
`PosterImageIdempotencyKeyRingSettings.java`, `PosterImageIdempotencyConfig.java`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java`
- Create:
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java`
- Create:
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java`
- Modify:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java`
- Modify:
`src/sample-portfolio/src/main/resources/application.yml`
- Modify:
`.github/workflows/object-storage-qualification.yml`
- Modify:
`.github/ci-gate-matrix.yml`
- [ ] **Step 1: Register the non-skipping migration lane and locks**
Register `posterImageMigrationTest` with Docker/PostgreSQL prerequisites that fail when absent, add
it to the container CI workflow/gate matrix, then regenerate/review the lock before using the lane
as RED:
```bash
cd src
./gradlew :sample-portfolio:resolveAndLockAll --write-locks
./gradlew :sample-portfolio:verifyDependencyLocks --console=plain
cd ..
bash .github/scripts/verify-gate-matrix.sh
```
Expected: the task/configuration exists and locks are valid; no migration behavior is claimed yet.
- [ ] **Step 2: Write failing migration/repository tests**
Run real PostgreSQL/Flyway and prove:
- fresh V1V8 and V7→V8 upgrade;
- V6 is unchanged;
- existing `poster.image_key` rows remain readable through the internal dual-read mapper;
- new READY attachments store only bounded opaque `image_reference`;
- legacy rows map to a discriminated legacy attachment and remain readable/publishable in the
approved compatibility window; canonical writes attach only READY published references;
- pending state lives in the intent, not as an attached domain image;
- active operation/idempotency uniqueness;
- a bounded, non-reversible idempotency-scope digest (not the raw header/principal) maps retries to
the committed operation, and the stored request fingerprint detects scope reuse with new intent;
- state/revision/fence CAS permits one winner;
- `SUPERSEDED` is a durable terminal intent state and requires a same-transaction exact
losing-reference retirement row when publication already occurred;
- intent survives Poster deletion long enough to reconcile/retire;
- no object payload or presigned URL column exists;
- replacement/delete work uses a separate durable `poster_image_retirement_intent` row and cannot
be inferred from a deleted Poster or overloaded upload intent.
The V8 intent table must include stable operation identity/epoch/destination, request fingerprint,
bounded idempotency-scope digest, expected poster version, exact content identity/media type, state,
stage handle, handoff claim/fence/expiry, published reference/version, replaced reference,
retry/error timestamps, and optimistic revision. Index the unique scope, operation, worker claims,
and expiry. Avoid a cascading FK that deletes required cleanup evidence.
Store idempotency lookup as `(hmac_key_version, digest)`, never raw scope or plain SHA-256. The
HMAC input is a frozen `poster-image-idempotency-scope-v1` domain separator followed by
length-prefixed UTF-8 fields for tenant presence/value, principal, use-case, and Idempotency-Key;
ambiguous concatenations and tenant A/B must produce different golden vectors. Load an operator-supplied,
permission-checked versioned key-ring file from
the typed `app.poster-image.idempotency.key-ring-path` setting; do not add it to root `src/.env` or
an app-bootstrap-only env registry. The canonical production profile requires a nonempty active
key. Retain old keys for at least the maximum intent/idempotency/reconciliation horizon and rotate
overlap-first. A DB-coordinated key-epoch row is locked in the same reservation transaction: all
pods must possess the DB-active key; a stale pod fails new admission, and activation changes only
after every pod has the new retained key. Reservation queries all retained-version digests before
inserting under the active version. Tests race old/new pods across activation and prove one intent,
same-scope lookup, and no key material disclosure. Do not reuse the logging/privacy salt.
Before invoking the generic executor, derive a storage-safe `IdempotencyContext`: the optional
tenant and principal dimensions become separately domain-separated HMAC aliases, the
`idempotencyKey` dimension becomes the full versioned scope digest, and `useCaseName` is the fixed
publication-reservation identifier. The raw client key, tenant, and principal remain request-memory
inputs only. Tenant presence/value remains part of every alias/digest, so tenant isolation is not
collapsed. Golden and ambiguous-tuple tests cover both the intent key and sanitized generic scope.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :sample-portfolio:test \
--tests '*PosterImageReferenceTest' \
--tests '*PosterTest' \
--tests '*HmacPosterImageIdempotencyScopeDigesterTest' \
--tests '*PosterImageIdempotencyConfigTest' \
--tests '*PosterImageUploadIntentRepositoryIntegrationTest' \
--tests '*PosterRepositoryAdapterIntegrationTest' --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
```
Expected: compilation/migration failures because V8, the intent model, key-ring binding, and
non-skipping qualification lane do not exist. Docker/PostgreSQL absence is an actionable failure,
not a skip or passing default test.
- [ ] **Step 4: Implement additive persistence only**
Do not call object storage from Flyway, an entity callback, repository mapper, or transaction
listener. Keep `image_key` for dual read; add `image_reference`, upload intent, and retirement
intent additively. During the approved window, the domain represents legacy versus published
attachment explicitly and permits existing legacy attachments; the canonical writer accepts only
a READY opaque reference. Keep the existing `imageKey()` accessor, legacy command, and
`PosterImageAttached` event source-compatible through this task so all current main sources compile.
- [ ] **Step 5: Verify GREEN**
Run the exact Step 3 commands again, then verify locks:
```bash
cd src
./gradlew :adapter:outbound:persistence-jpa:test \
--tests '*IdempotencyStoreAdapterTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*PosterImageReferenceTest' \
--tests '*PosterTest' \
--tests '*HmacPosterImageIdempotencyScopeDigesterTest' \
--tests '*PosterImageIdempotencyConfigTest' \
--tests '*PosterImageUploadIntentRepositoryIntegrationTest' \
--tests '*PosterRepositoryAdapterIntegrationTest' --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
./gradlew :sample-portfolio:resolveAndLockAll --write-locks
./gradlew :sample-portfolio:verifyDependencyLocks --console=plain
```
Expected: PASS, and the `posterImageMigrationTest` result XML reports zero skipped tests.
- [ ] **Step 6: Verify rollback window**
The non-skipping `PosterImageV8MigrationQualificationTest` must run a V7-compatible SQL/JPA
projection against the expanded schema, including old INSERT/UPDATE/read behavior. Document that
rollback is binary-only while old code ignores additive columns; there is no down migration,
column drop, intent deletion, or object mutation.
### Task 22: Move Poster publication through short transactions and crash-safe handoff
**Files:**
- Create:
`src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java`
- Create:
`src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java`
- Modify:
`src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java`
- Modify:
`src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java`
- Modify:
`src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java`
- Test:
`src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepositoryTest.java`
- Modify:
`src/adapter/outbound/persistence-jpa/README.md`
- Modify:
`src/adapter/outbound/persistence-jpa/CLAUDE.md`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationResult.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintFactory.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintCodec.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageUploadReconciliationJob.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`:
`PosterImageUploadReconciliationSettings.java`, `PosterImageObjectStorageConfig.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/`:
`PosterImageAttachmentPrepared.java`, `PosterImageAttachmentReadyV2.java`,
`PosterImagePublicationEventPublisher.java`
- Modify:
`src/sample-portfolio/src/main/resources/application.yml`
- Create:
`docs/evidence/object-storage/poster-image-event-consumers.md`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicyTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventContractTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfigTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationCrashMatrixTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationConcurrencyTest.java`
- Test:
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageReservationAtomicityQualificationTest.java`
- [ ] **Step 1: Write failing transaction-boundary tests**
Use a tracking `TransactionPort` and fake object ports. Fail the test if producer invocation,
stage/inspect/finalize/abort/resolve, or any external I/O occurs while a transaction is active.
Prove the order:
```text
TX1 reserve UploadIntent RESERVED and commit
outside TX stage/verify (and scan when configured)
object handoff claim
TX2 CAS intent RESERVED -> PENDING with matching fence and commit
append versioned AttachmentPrepared notification in the same TX
outside TX finalize publication
TX3 CAS poster expected version + intent PENDING -> READY,
insert exact replacement retirement intent and versioned AttachmentReady notification, commit
OR, when Poster CAS loses, transition this intent -> SUPERSEDED and insert an exact retirement
intent for this operation's already-published losing reference in the same transaction
release handoff claim
claim/reconcile the independent retirement intent outside TX
```
The committed `poster_image_upload_intent` state is the canonical work queue. A bounded polling
worker claims `PENDING` rows and renews the object handoff; the transactional outbox events are
versioned integration notifications, not the only wake-up or an object-operation journal. This
choice must be mirrored in the deep design before implementation.
- [ ] **Step 2: Write the failing crash-gap matrix**
Inject a process/application stop:
- after intent commit before stage;
- after data mutation before stage receipt;
- after stage before claim;
- after claim before DB PENDING;
- after DB PENDING before finalize;
- after finalize response loss;
- after publish before DB READY;
- after DB READY before claim release;
- before and after the single TX1 commit that atomically covers generic idempotency claim,
UploadIntent create-or-read, and generic reservation completion.
Retry/reconciler must reuse the stable operation, avoid producer replay after staged evidence exists,
attach only exact READY reference, and never delete merely because a row is absent. The
non-skipping PostgreSQL atomicity test must prove a kill/failure before commit leaves neither row,
while commit leaves both COMPLETED generic reservation and matching intent—never a durable generic
`IN_FLIGHT` row without an intent. It also races two same-scope transactions and proves the loser
replays/commits normally, and proves expired reclaim.
A worker must not attempt to recreate request bytes for a `RESERVED` intent. If the process dies
after TX1 but before staging, a same-key/same-fingerprint HTTP retry supplies a fresh producer and
continues the same operation; without retry, bounded intent expiry may authorize abort/report, not
invent data or publish. Once exact staged evidence exists, retry skips producer invocation.
- [ ] **Step 3: Write failing concurrency tests**
Cover same idempotency key/same fingerprint, same key/different fingerprint, two replacement
operations against one expected Poster version, reserve-expiry versus late PENDING, abort
authorization versus finalize, and Poster deletion during replacement. Prove deterministic CAS
winners. If finalize already published before the Poster CAS loses, commit `SUPERSEDED` plus a
dedicated exact losing-reference/version retirement row in the same transaction; a thrown/rolled
back CAS path is forbidden. Prove that row survives deletion, worker takeover, response loss, and
process restart. Prove the versioned
ready event exposes an opaque reference rather than a raw key. The old
`poster.image-attached`/`imageKey` contract is unchanged; the Gate A choice controls a new event
type/envelope or bounded dual publish, with checked-in consumer inventory and rollback evidence.
Freeze `poster-image-publication-fingerprint-v1` as length-prefixed canonical bytes over schema,
Poster ID, expected Poster version, destination/profile, normalized media type, exact declared
length, and caller-supplied full-file SHA-256. Exclude multipart boundary, part/header ordering,
filename, and transport framing. Golden vectors prove two encodings of the same multipart semantics
match, while any semantic field change conflicts. The factory runs before TX1 without consuming the
file stream; staging recomputes byte count/SHA-256 and rejects a mismatch.
- [ ] **Step 4: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:persistence-jpa:test \
--tests '*IdempotencyStoreAdapterTest' \
--tests '*PostgreSqlIdempotencyClaimRepositoryTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*PosterImagePublicationPolicyTest' \
--tests '*PosterImagePublicationFingerprintTest' \
--tests '*PosterImagePublicationEventContractTest' \
--tests '*PosterImageObjectStorageConfigTest' \
--tests '*PosterImagePublicationWorkflowTest' \
--tests '*PosterImagePublicationCrashMatrixTest' \
--tests '*PosterImagePublicationConcurrencyTest' --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest \
--tests '*PosterImageReservationAtomicityQualificationTest' --console=plain
```
Expected: new recoverable reservation/publication symbols are absent. The existing legacy
`UploadPosterImageCommand`, `UploadPosterImageUseCase`, controller, response, and wire test remain
unchanged and must still compile/pass in this task.
- [ ] **Step 5: Implement the minimum workflow**
The new `PublishPosterImageCommand` may carry the framework-free `ObjectContentProducer`, exact
content identity, media type, `CallBudget`, cancellation, and `IdempotencyContext`; it must not carry
`MultipartFile` or another inbound type. Keep the legacy command/use case intact until Task 23.
`PublishPosterImageUseCase` is `Idempotency.KEYED`, but the generic executor wraps only TX1
create-or-read reservation and immediately stores the bounded stable operation/intent result.
Build `RequestFingerprint` only from the canonical semantic fingerprint above, never raw multipart
bytes/boundary.
The exact shape is
`tx.inWrite(() -> idempotencyExecutor.execute(sanitizedContext, createOrReadIntent,
reservationCodec))`;
the existing JPA idempotency store participates in that caller transaction, and the reservation is
small enough to stay inline. Thus generic claim, durable intent, and generic COMPLETED response
commit or roll back together. The POST-facing use case always returns that same `202` reservation;
it never mixes a later READY payload into the generic replay record. In that same HTTP invocation,
after TX1 commits, consume the request-bound producer to stage/verify outside TX, acquire handoff,
and commit TX2 PENDING; only then return the stable reservation. Finalize/TX3 READY is keyed by the
operation and owned by the bounded intent worker. The authorized status query reads the intent and
returns a locator-free progress/result view. No remote I/O or producer invocation occurs in TX1,
and no worker reads a request producer after the response. Allocate the operation ID only in the
committed intent and reuse it after generic replay/expiry. Application policy owns Poster
media/size, permission, and idempotency semantics; transport and destination policies do not, and
this sample does not invent resource ownership.
Before relying on the outer transaction, add a vendor-neutral `IdempotencyClaimRepository` SPI and
implement this exact statement only in the allowed `.postgresql` package:
`INSERT ... ON CONFLICT ON CONSTRAINT uq_idempotency_scope DO UPDATE SET
id=EXCLUDED.id, request_hash=EXCLUDED.request_hash, status='IN_FLIGHT',
response_payload=NULL, response_ref=NULL, created_at=EXCLUDED.created_at,
expires_at=EXCLUDED.expires_at WHERE idempotency_record.expires_at <= :now RETURNING id`.
One returned ID means a new/expired claim won; no row means a live winner exists. A uniqueness
exception must never poison the caller transaction.
Because executor `find` can load an expired entity before native reclaim, detach only that exact
expired `IdempotencyRecordEntity` before the claim and reload the returned ID/fingerprint before
complete; never call `EntityManager.clear()` or detach unrelated business entities. The
sample/PostgreSQL configs explicitly select this implementation; no vendor SQL enters the generic
idempotency package. Preserve the existing application port contract and prove winner/loser,
expired-find→reclaim→complete with new fingerprint/expiry/COMPLETED state, and unrelated managed
entity preservation in non-skipping real PostgreSQL. Do not emulate with a process lock. Update the
owner README/CLAUDE vendor SPI table. The same qualification queries both `idempotency_record` and
V8 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored
aliases/digests retain tenant separation.
Reconciliation settings are constructor-bound, disabled by default, and bound batch size,
claim/renew duration, fixed delay, retry/backoff, concurrency, and shutdown grace. The disabled
context creates no scheduler/thread and performs no DB/object access. The explicit canonical sample
profile enables the new capability; the legacy-only profile remains unchanged until Task 23.
- [ ] **Step 6: Verify GREEN**
Run the command from Step 4 plus:
```bash
cd src
./gradlew :sample-portfolio:test \
--tests '*PosterControllerWireTest' \
--tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain
```
Expected: PASS with both the new semantic application slice and unchanged legacy slice compiling;
no endpoint switches in this task.
### Task 23: Add bounded multipart ingress and an approved locator-free response
**Files:**
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/`:
`PosterImagePublicationResponse.java`, `PosterImagePublicationStatusResponse.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/`:
`PosterImagePublicationController.java`, `LegacyPosterImageController.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`:
`PosterImageApiSettings.java`, `PosterImageApiConfig.java`
- Move:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java`
to
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/legacy/LegacyUploadPosterImageCommand.java`
- Move:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java`
to
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/legacy/LegacyUploadPosterImageUseCase.java`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
- Modify:
`src/sample-portfolio/src/main/resources/application.yml`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageControllerWireTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationStatusAuthorizationTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfigTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/architecture/PosterImageIngressArchitectureTest.java`
- Modify:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java`
- Modify:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java`
- Modify:
`src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json`
- Create:
`src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-publication-snapshot.json`
- Modify:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java`
- Modify, only if Approval Gate A selects a new header:
`docs/registries/headers.yaml`
- [ ] **Step 1: Write failing bounded-ingress tests**
Prove:
- no `MultipartFile.getBytes()` or full file materialization;
- inbound adapter opens/closes the multipart stream inside the producer callback;
- request-bound producer is fully consumed before POST returns; any attempted use after response is
rejected, and a background worker never receives a `MultipartFile`/request stream;
- chunks do not exceed the application sink limit;
- declared/exact size and SHA-256 representation are validated;
- client disconnect/read failure/cancellation is not upload success;
- controller/producer signatures leak no `MultipartFile` into sample application/domain;
- transport multipart/body hard limit and header syntax stay inbound; Poster allowed media/logical
size/permission/idempotency stay in `PosterImagePublicationPolicy`/use case; destination
max/checksum/encryption stay in the compiled object-storage binding. Architecture tests reject
those business rules in controller/mapper/producer/configuration.
- [ ] **Step 2: Write the failing wire/API contract**
For the exact approved endpoint, prove:
- required authorization and `Idempotency-Key`;
- accepted digest input and canonical mismatch error;
- POST returns `202` only after exact stage evidence and TX2 PENDING are durable, with the stable
opaque publication operation and status link replayed for the same key/fingerprint;
- validation/read/stage/TX2 failure returns the normalized 4xx/5xx and does not consume the
request stream after response; a same-key/same-body retry reuses the reservation/operation and
supplies the producer again until staged evidence exists;
- authorized GET status returns progress and, only at READY, opaque reference, size, media type, and
logical digest; operation tokens are unguessable but not authorization, missing operation is 404,
and missing `poster:write` is rejected before lookup;
- the new publication/status DTOs contain no raw key, location, bucket, path, `file://`, `s3://`,
presigned URL, provider version, or internal handle;
- legacy endpoint/response exists only under the explicit compatibility profile;
- legacy and canonical image controllers are distinct conditional beans and never active
simultaneously; disabled mode exposes neither;
- signing/direct endpoints are absent.
The pre-existing general `PosterResponse.imageKey` and legacy `StoredObjectResponse` remain
unchanged during the approved REST compatibility window. Therefore this task claims only the new
publication surface is locator-free; removing/versioning the old field requires consumer inventory,
a separate API version/rollback approval, and new snapshot. Do not silently edit both DTO shapes.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :sample-portfolio:test \
--tests '*MultipartObjectContentProducerTest' \
--tests '*PosterImagePublicationControllerWireTest' \
--tests '*LegacyPosterImageControllerWireTest' \
--tests '*PosterImagePublicationStatusAuthorizationTest' \
--tests '*PosterImageApiConfigTest' \
--tests '*PosterImageIngressArchitectureTest' \
--tests '*LegacyPosterImageUploadCharacterizationTest' \
--tests '*PosterControllerWireTest' \
--tests '*OpenApiDriftContractTest' --console=plain
```
Expected: new producer/controllers/settings/status contracts do not exist. The existing controller
still materializes bytes and the approved dual-profile snapshots are absent.
- [ ] **Step 4: Implement the approved additive API**
Keep each controller thin. Use the framework-free producer bridge and explicit conditional config;
never place both image mappings in one controller or use component scanning as a profile switch.
Remove stereotype auto-registration from the moved legacy use case/controller and construct them
only through the legacy condition. The legacy command/use case/controller live only in the named
allowlisted `..legacy..` slice, and update the frozen ArchUnit exception to that exact package. No
other sample application package imports `application.storage`. Do not change legacy DTO/general
Poster response shape in place. When Approval Gate A authorizes the new surface,
regenerate the snapshot intentionally:
```bash
cd src
./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange --console=plain
```
Review the diff for only approved changes. The approval flag is not blanket authorization for
unrelated OpenAPI drift.
- [ ] **Step 5: Verify GREEN**
Run the exact Step 3 command plus:
```bash
cd src
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
```
Expected: PASS in disabled, legacy-only, and publication-only profile matrices. The only old-port
import is the exact legacy slice; canonical controller/status/application paths have zero legacy
imports, and direct endpoints remain absent.
### Task 24: Add report-first legacy adoption, replacement retirement, and removal gates
**Files:**
- Create under
`src/application-core/src/main/java/dev/caskeleton/application/storage/migration/`:
`LegacyObjectAdoptionPort.java`, `LegacyObjectAdoptionRequest.java`,
`LegacyObjectAdoptionReceipt.java`, `LegacyObjectLocator.java`,
`LegacyObjectAdoptionApproval.java`, `LegacyObjectAdoptionApprovalVerifierPort.java`
- Modify:
`src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`:
`LegacyAdoptionApprovalDocument.java`, `LegacyAdoptionApprovalCodec.java`,
`Ed25519LegacyAdoptionApprovalVerifier.java`, `LegacyAdoptionApprovalReplayRecord.java`,
`LegacyAdoptionApprovalReplayStore.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java`
- Create:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/`:
`AdoptLegacyPosterImageCommand.java`, `RetirePosterImageCommand.java`,
`ReconcilePosterImageRetirementCommand.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`:
`LegacyPosterImageAdoptionResult.java`, `PosterImageRetirementResult.java`,
`LegacyPosterImageAdoptionAuthorizationPolicy.java`,
`LegacyPosterImageAdoptionExecutionIdentity.java`,
`LegacyPosterImageAdoptionExecutionIdentityPort.java`
- Create under
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`:
`PosterImageRetirementJob.java`, `PosterImageRetirementSettings.java`,
`PosterImageRetirementConfig.java`,
`LegacyPosterImageAdoptionSettings.java`, `LegacyPosterImageAdoptionConfig.java`,
`LegacyPosterImageAdoptionMaintenanceRunner.java`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java`
- Modify:
`src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfigTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageLegacyMigrationIntegrationTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/LegacyPosterImageAdoptionConfigTest.java`
- Test:
`src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageAdoptionAuthorizationTest.java`
- Test:
`src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java`
- Create:
`docs/evidence/object-storage/poster-legacy-migration.md`
- [ ] **Step 1: Write failing report/adoption tests**
Prove report-first ordering:
```text
inventory raw key
-> exact HEAD/read digest/media/size/version
-> create immutable manifest/reference with a stable adoption operation
-> DB row compare-and-swap to opaque reference
-> retain dual read
-> separately authorize old-object retirement
```
Missing/corrupt/retained/unknown-version legacy objects are reported/quarantined, never overwritten,
renamed, copied, or deleted automatically. A DB CAS loser leaves evidence for reconciliation and
does not publish itself as current. Re-running returns the same reference/receipt.
- [ ] **Step 2: Write failing replacement/delete retirement tests**
Poster replacement and deletion must create retirement work with exact reference/version and
retention/handoff fence. Business delete is logical retirement, not privileged purge. A failure to
retire cannot resurrect the Poster or silently discard cleanup work. Prove same-transaction enqueue
for TX3 winner's replaced reference, TX3 loser's already-published `SUPERSEDED` reference, and
business delete; independent claim/lease/fence/retry after the Poster row is gone; two-worker CAS;
process death during retirement; lost retirement response resolved by exact reference/version
inspect; and durable terminal/held evidence. The worker never receives
`ObjectPurgeMaintenancePort`.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :application-core:test \
--tests '*ObjectStorageArchitectureContractTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*LegacyObjectAdoptionServiceTest' \
--tests '*ObjectStorageLegacyMigrationConfigTest' \
--tests '*LegacyAdoptionApprovalVerifierTest' \
--tests '*ObjectControlRecordCodecTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*PosterImageLegacyMigrationIntegrationTest' \
--tests '*DeletePosterImageRetirementTest' \
--tests '*PosterImageRetirementCrashMatrixTest' \
--tests '*PosterImageRetirementConfigTest' \
--tests '*LegacyPosterImageAdoptionConfigTest' \
--tests '*LegacyPosterImageAdoptionAuthorizationTest' --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest \
--tests '*PosterImageRetirementQualificationTest' --console=plain
```
Expected: failures because adoption and retirement workflows do not exist.
- [ ] **Step 4: Implement report-only, then reviewed apply**
The first runnable mode emits a bounded report and performs no mutation. Apply mode requires an
explicit reviewed manifest of candidates and per-row CAS. Flyway never performs backfill. Keep old
route/binding readers and legacy objects until observation proves zero legacy reads and all
missing/corrupt cases are resolved. The application contract for this operation remains in the
deprecated legacy migration namespace and may be injected only into the named administrative
adoption use case; no normal business endpoint may use it. `LegacyObjectLocator` is capped at 1,024
UTF-8 bytes, rejects controls, redacts `toString`/errors, never appears in adoption receipts, and is
the exact architecture-tested exception to the new semantic no-locator rule.
The adoption use case lives in the allowlisted `..poster.migration..` package, requires the exact
administrative permission, and is assembled only in an explicit maintenance profile. Settings bind
report path, reviewed-manifest path/digest, batch size, deadline, and mode; default is disabled and
`REPORT_ONLY`. `APPLY` additionally requires a detached, canonical
`LegacyObjectAdoptionApproval` document. Its JDK-only length-prefixed codec binds the approval
schema version, adoption operation ID, exact reviewed-manifest SHA-256, legacy namespace digest,
target destination/namespace, literal mode `APPLY`, `notBefore`, expiry, nonce, and two distinct
approver key IDs. Both approvers independently sign those exact canonical bytes with Ed25519.
Trusted public keys come only from permission-checked configured files; inline or manifest-supplied
keys are rejected. The verifier checks canonical re-encoding, both signatures, distinct trusted
approvers, every binding, time window, and bounded manifest bytes before any mutation.
After verification, a durable replay record CAS-binds the nonce to the same operation, manifest,
namespace pair, and approval digest. A terminal replay of that exact operation is idempotent;
reuse for any other operation or digest fails closed. `LegacyPosterImageAdoptionAuthorizationPolicy`
then requires the isolated maintenance execution identity, exact administrative capability, and
verified approval receipt before invoking the adoption port. The maintenance runner supplies that
identity explicitly; it neither depends on a web `SecurityContext` nor treats
`@RequiresPermission` as scheduler authorization. Call order is:
```text
load bounded reviewed manifest and signed approval
-> verify canonical bytes, two signatures, bindings, time window, and execution identity
-> claim/replay-CAS nonce for the exact adoption operation
-> re-read and re-hash the immutable manifest
-> per-row inspect/digest and CAS adoption
-> mark exact replay record terminal with evidence
```
Tests fail closed for absent or malformed approval, forged signature, one signer, the same signer
twice, untrusted key, wrong operation/manifest/legacy namespace/target namespace/mode, expired or
not-yet-valid approval, nonce replay against a different binding, changed manifest bytes, missing
or mismatched maintenance identity, and normal-context invocation. `REPORT_ONLY` does not require
an APPLY approval and cannot enter the mutating path. The normal web/application context exposes
neither the raw-locator port nor an adoption entrypoint.
Neither the adapter adoption service nor the sample adoption use case/job uses a component
stereotype. Explicit maintenance configurations assemble them only after mode/profile, reviewed
digest, signed-approval trust store, permission boundary, maintenance identity, and isolated
legacy/new namespaces validate; normal and canonical publication contexts assert zero
migration-port/verifier/replay-store/runner beans and zero side effects. The closed control codec
registers approval replay records with golden/new-reader/old-reader compatibility fixtures.
Retirement settings are constructor-bound and disabled by default, with bounded claim/renew,
batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V8
retirement table and calls only logical exact-reference retirement outside DB transactions. A
disabled context creates no scheduler/thread; stale fence/takeover is rejected and response loss
remains operation-keyed exact-reference/version reconciliation, not blind retry. TX3 replacement and
`DeletePosterUseCase` insert the row in the same DB transaction; its FK/retention preserves work
after Poster deletion. Physical purge remains separately privileged in Tasks 2629.
All three named use cases implement the repository `CommandUseCase` convention with explicit
commands/results and `@UseCaseCapability`; jobs invoke those boundaries rather than calling a
repository/port directly. Adoption carries the admin-only legacy exception, while logical
retirement/reconciliation use only opaque exact references.
- [ ] **Step 5: Run Batch E checkpoint**
Run:
```bash
cd src
./gradlew \
:application-core:check \
:adapter:outbound:persistence-jpa:check \
:adapter:outbound:objectstorage:check \
:sample-portfolio:check --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot --console=plain
```
Expected: PASS. Record sample workflow R1 evidence only. Do not remove legacy types, columns,
objects, readers, or endpoint merely because the new happy path passes.
---
## Approval Gate B — Production AWS topology and authority
Do not execute Batch F protected/provider mutations until an authorized deployment owner provides:
- a pre-provisioned regional AWS S3 general-purpose bucket plus isolated disposable qualification,
backup-export, and restored-destination namespaces;
- exact account/bucket owner, region, endpoint/network path, ownership controls, Block Public
Access, versioning, lifecycle, incomplete-multipart policy, encryption/KMS, retention profile,
bounded `s3:signatureAge` policy, qualified clock-skew source, and CORS decisions;
- a workload role/default-chain credential path with least-privilege data/control/qualification/
maintenance separation and credential-refresh evidence;
- an IaC-produced, expiry-bounded, Ed25519-signed capability attestation and trusted public key;
- permission to run non-destructive safe probes and separately authorized cleanup in the
qualification namespace;
- permission to create a bounded backup of exact test data/control versions and restore it into the
disposable restored namespace for R2 reconciliation evidence; this is not authority for a
regional production DR game day;
- KMS/S3 throttle, permission revocation, process kill, network fault, and cleanup test windows;
- CI secret handling and evidence retention;
- the exact cards/destination profiles proposed for R2.
- Object-Lock-enabled topology and separate privileged retention/hold/purge role only if the
retention card is proposed; otherwise that card remains below R2.
The runtime must not create or alter buckets, IAM, KMS, lifecycle, versioning, Object Lock, BPA,
ownership controls, or CORS. If the authority/topology is unavailable, complete the code/tests that
do not require it and leave the exact card below R2; do not substitute MinIO or mocks.
## Batch F — Phase 6: Production security, maintenance, and exact R2 evidence
### Task 25: Verify signed deployment attestation and fail-closed AWS startup qualification
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/`:
`ObjectStorageDeploymentAttestation.java`, `ObjectStorageAttestationCodec.java`,
`Ed25519ObjectStorageAttestationVerifier.java`, `ObjectStorageSafeProbe.java`,
`AwsS3StartupQualifier.java`, `ObjectStorageQualificationCache.java`,
`ObjectStorageQualificationFailure.java`, `ObjectStorageClockHealth.java`,
`AwsS3VersioningPropagationQualifier.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/security/`:
`ObjectStorageCredentialPolicy.java`, `ObjectStorageEndpointPolicy.java`,
`ObjectStorageEncryptionPolicy.java`, `ObjectStorageSecurityValidator.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageAttestationVerifierTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3StartupQualifierTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/security/ObjectStorageSecurityConfigurationTest.java`
- [ ] **Step 1: Write failing attestation tests**
Use deterministic Ed25519 fixtures and reject:
- invalid signature, non-canonical document, unknown/newer schema;
- expired/not-yet-valid evidence or expiry beyond configured maximum;
- wrong account/owner/bucket/region/provider/deployment identity;
- binding/policy/encryption/versioning/lifecycle/ownership/BPA/profile digest mismatch;
- missing/mismatched `versioningActivatedAt`, approved propagation-soak duration,
maximum `s3:signatureAge`, time-source identity, or qualified maximum clock skew;
- an attestation that advertises a card combination not explicitly qualified;
- key/document path traversal, symlink, world-writable file, oversize, or value leakage;
- last-known-good use without a signed grace policy.
- [ ] **Step 2: Write failing startup/security tests**
Prove:
- required destination fails startup on missing/mismatched evidence;
- minimal probe uses expected owner and only the reserved qualification namespace;
- probe does bounded create/HEAD/GET/conditional-CAS/delete only when authorized;
- no provisioning/config mutation API is called;
- production requires HTTPS, approved public presign host, default-chain temporary credentials,
expected owner, private ownership/BPA, and approved encryption profile;
- new grant admission requires healthy time synchronization and an expiry strictly inside both
credential horizon minus qualified skew and the attested signature-age ceiling;
- after the attested versioning activation time plus approved soak, a bounded sentinel
create/read/new-version/exact-version-delete/delete-marker check passes before mutation admission;
a recent/unknown activation or failed sentinel keeps the destination unqualified;
- static access/secret literals, anonymous credentials, public ACL, governance bypass, plaintext
endpoint, and auto-create fail;
- disabled/unselected provider performs no attestation read, credential resolution, client
creation, DNS, or probe.
- [ ] **Step 3: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageAttestationVerifierTest' \
--tests '*AwsS3StartupQualifierTest' \
--tests '*ObjectStorageSecurityConfigurationTest' \
--tests '*S3ProviderCompositionTest' --console=plain
```
Expected: compilation failure because attestation/production qualification does not exist.
- [ ] **Step 4: Implement bounded verification and qualification**
Use JDK Ed25519 and strict canonical JSON. Cache exact evidence with expiry and refresh before its
horizon. Refresh failure never recompiles an existing operation against current settings. After
expiry, block new required mutations/grants; published exact-version reads continue only under an
explicit signed read-continuity policy plus live safe probe. Emit redacted audit/metrics later in
Task 28. Versioning propagation is evidence, not a sleep inside startup: compare the signed
activation instant to the approved soak and run the sentinel only after the horizon. Wire
qualification only through the selected S3 contribution; disabled/unselected contexts remain
side-effect free and the normal context still has no privileged purge bean.
- [ ] **Step 5: Verify GREEN**
Run the command from Step 3. Expected: PASS.
### Task 26: Implement version-aware retirement, retention evidence, and privileged exact purge
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`:
`VersionAwareObjectRetirementService.java`, `PrivilegedObjectPurgeService.java`,
`ObjectRetentionDecision.java`, `ObjectPurgeAuthorization.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`:
`S3RetentionEvidenceReader.java`, `S3VersionAwarePurgeProvider.java`,
`S3PrivilegedPurgeProviderContribution.java`, `S3PrivilegedPurgeClientFactory.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeSettings.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfig.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedProviderAssembler.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfigTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PrivilegedPurgeCompositionTest.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/VersionAwareObjectRetirementTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/PrivilegedObjectPurgeTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3RetentionEvidenceReaderTest.java`
- [ ] **Step 1: Write failing lifecycle/privilege tests**
Prove:
- retirement makes a published reference unavailable through business read/grant but does not
imply physical deletion;
- exact object version, reference revision, handoff fence, retention policy revision, and purge
operation are required;
- a delete marker is not evidence that a noncurrent version was physically purged;
- active retention/legal hold yields `HELD`, not success or bypass;
- `HELD` requires exact successful `GetObjectRetention`/`GetObjectLegalHold` evidence; a generic
`403`, timeout, unavailable API, or unmapped provider error is `UNKNOWN`/fail-closed, never held;
- governance bypass is absent from the normal role/path;
- general business composition cannot obtain `ObjectPurgeMaintenancePort`;
- response loss resolves exact version state before retry;
- object/version mismatch, unknown schema, or missing authorization never deletes.
- if `object-storage-retention` is proposed at R2, versioning, exact noncurrent-version purge,
delete-marker behavior, lifecycle interaction, retention/legal-hold reads, and the split
privileged role are mandatory; otherwise the card remains below R2.
- [ ] **Step 2: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*VersionAwareObjectRetirementTest' \
--tests '*PrivilegedObjectPurgeTest' \
--tests '*S3RetentionEvidenceReaderTest' \
--tests '*ObjectStoragePrivilegedPurgeConfigTest' \
--tests '*S3PrivilegedPurgeCompositionTest' --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
```
Expected: compilation failure because version-aware retirement/purge does not exist.
- [ ] **Step 3: Implement split business and maintenance paths**
Retirement is a reference-state CAS. Purge uses a separately composed least-privilege provider and
exact version API. Read exact retention/legal-hold APIs only for profiles that claim them; map
ambiguous permission/error results to unknown. The normal application context has zero
`ObjectPurgeMaintenancePort` beans. Only an explicit protected maintenance context/configuration
with separately compiled binding, workload credentials, S3 client, contribution/assembler, and
close lifecycle may expose the privileged router. Construction happens only after selected
attestation/settings validation and never reuses or casts the normal contribution. Tests cover
disabled/unselected/invalid/selected/close and prove the normal context performs zero privileged
credential lookup/client creation. ArchUnit forbids injection outside approved maintenance/
bootstrap packages. Keep purge disabled/report-only at composition until Task 27 and protected
security tests pass.
- [ ] **Step 4: Verify GREEN**
Run the command from Step 2. Expected: PASS. Do not promote the retention card from mocked tests.
### Task 27: Add fenced reconciliation, report-first cleanup, and epoch compaction
**Files:**
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`:
`ObjectStorageMaintenanceLease.java`, `ObjectStorageMaintenanceLeaseStore.java`,
`ObjectStorageCleanupPlanner.java`, `ObjectStorageCleanupCandidate.java`,
`ObjectStorageMaintenanceRunner.java`, `ObjectStorageCleanupMode.java`,
`ObjectOperationReconciler.java`, `ObjectMultipartReaper.java`,
`ObjectRetiredVersionReaper.java`, `ObjectOperationEpochCompactor.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/`:
`ObjectStorageMaintenanceSettings.java`, `ObjectStorageMaintenanceConfig.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java`
- Modify:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java`
- Modify:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageMaintenanceLeaseTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageCleanupPlannerTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageMaintenanceRaceTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectOperationEpochCompactorTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceConfigTest.java`
- [ ] **Step 1: Write failing lease/candidate tests**
Prove:
- one current `(owner token, fence, expiry)` controls a destination/job;
- expired owner cannot update/delete after takeover;
- LIST discovers candidates only; exact control/data/reference/session GET establishes truth;
- age, LIST absence, worker lease expiry, or missing application row alone never authorizes delete;
- candidate needs supported schema, terminal/eligible state, exact version, retention result, handoff
authorization, replay horizon, and no active direct/scan/multipart generation;
- unknown/newer/corrupt records are report-only quarantine.
- [ ] **Step 2: Write failing cleanup/late-operation races**
Cover cleanup versus late finalize, grant in-flight horizon, part acknowledgement, DB PENDING CAS,
retention activation, legal hold, claim renewal, operation response loss, and process kill at every
report/quarantine/delete/control-CAS step. A stale worker must not remove an artifact committed by a
newer fence.
- [ ] **Step 3: Write failing epoch-compaction tests**
Prove:
- only sealed epochs beyond all replay/indeterminate/retention horizons compact;
- an immutable rejection record is durable before per-operation tombstones are removed;
- any operation in a sealed/compacted epoch returns `OPERATION_EXPIRED`;
- active/draining epoch, live session/reference, missing old binding/policy revision, or unknown
schema blocks compaction;
- epoch tokens are never reused.
- [ ] **Step 4: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageMaintenanceLeaseTest' \
--tests '*ObjectStorageCleanupPlannerTest' \
--tests '*ObjectStorageMaintenanceRaceTest' \
--tests '*ObjectOperationEpochCompactorTest' \
--tests '*ObjectStorageMaintenanceConfigTest' \
--tests '*ObjectControlRecordCodecTest' --console=plain
```
Expected: compilation failure because maintenance coordination does not exist.
- [ ] **Step 5: Implement report-only first**
Default maintenance is disabled; first production activation is `REPORT_ONLY`. `QUARANTINE` and
`DELETE` require explicit reviewed settings and exact qualified provider/card evidence; `DELETE`
also remains unavailable until Task 29 protected qualification and an explicit runtime approval
token. Constructor-bound settings cover enabled/mode, lease/renew, batch size, scan/list pages,
operation deadline, retry, concurrency, and shutdown grace. Invalid/unbounded combinations fail
before any provider/list/credential work, and disabled composition creates no runner/scheduler/
privileged bean. Persist audit decisions before destructive I/O and outcome evidence after.
Register maintenance lease/compaction record families explicitly in the closed codec with golden
and old-reader fixtures.
- [ ] **Step 6: Verify GREEN**
Run the command from Step 4. Expected: PASS.
### Task 28: Add low-cardinality observability, readiness, resource bounds, and graceful lifecycle
**Files:**
- Modify:
`src/adapter/outbound/objectstorage/build.gradle`
- Modify:
`src/adapter/outbound/objectstorage/gradle.lockfile`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/observability/`:
`ObjectStorageMetrics.java`, `ObjectStorageAuditSink.java`,
`LoggingObjectStorageAuditSink.java`, `ObjectStorageTelemetryRedactor.java`
- Create under
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/`:
`ObjectStorageReadinessProbe.java`, `ObjectStorageReadinessSnapshot.java`,
`ObjectStorageReadinessFailure.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLifecycleCoordinator.java`
- Create:
`src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageActuatorConfig.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/observability/ObjectStorageObservabilityTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessProbeTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageActuatorConfigTest.java`
- Test:
`src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLifecycleCoordinatorTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageResourceTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageResourceBoundTest.java`
- Modify:
`.github/workflows/object-storage-qualification.yml`
- Modify:
`.github/ci-gate-matrix.yml`
- Modify:
`.github/scripts/verify-gate-matrix.sh`
- Modify:
`docs/registries/metrics.yaml`
- [ ] **Step 1: Register the non-skipping resource lane and locks**
Register `objectStorageResourceTest`, add its secret-free bounded-resource job/release dependency to
the workflow/gate matrix, and lock the new configurations before behavioral RED:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
cd ..
bash .github/scripts/verify-gate-matrix.sh
```
Missing resource prerequisites fail the selected task; the task never silently skips.
- [ ] **Step 2: Write failing observability/redaction tests**
Cover logical operations versus physical attempts, bytes/chunks, latency, outcome/certainty,
admission rejection, pool acquire, retry amplification, indeterminate age, orphan/scan/multipart
age, evidence expiry, and cleanup decisions. Tags may include only bounded card/provider/profile/
operation/outcome values. Reject raw destination, tenant, reference, key, filename, bucket,
endpoint, request ID, URL/query, credential, ETag, upload ID, or exception message as metric tags.
Captured logs/traces/audits must redact the same sensitive values while retaining hashed
correlation tokens and normalized outcomes.
- [ ] **Step 3: Write failing readiness/lifecycle tests**
Prove:
- only enabled required destinations affect readiness;
- liveness does not depend on object storage;
- expired/mismatched qualification blocks new mutation/grant and reports exact redacted cause;
- optional destination outage follows its reviewed policy without changing required claims;
- disabled capability registers no probe/health/metrics/client;
- shutdown stops admission, drains managed callbacks within grace, persists cancellation or
indeterminate state, leaves durable direct sessions for recovery, releases maintenance lease,
closes presigner/client/executors exactly once, and leaks no thread/FD/buffer.
- [ ] **Step 4: Write the failing resource task**
Test increasing object size without linear heap growth; configured aggregate chunk/pool/multipart
bounds; slow producer/consumer; pool saturation; retry storm; concurrent range reads; in-flight
shutdown; direct memory, thread, and FD stability. The selected resource task may not silently skip.
- [ ] **Step 5: Verify RED**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageObservabilityTest' \
--tests '*ObjectStorageReadinessProbeTest' \
--tests '*ObjectStorageActuatorConfigTest' \
--tests '*ObjectStorageLifecycleCoordinatorTest' --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageResourceTest --console=plain
```
Expected: compilation/task failure because observability/readiness/resource/lifecycle support does
not exist.
- [ ] **Step 6: Implement with reviewed dependencies**
Add `io.micrometer:micrometer-core` under existing dependency management, update the lockfile, and
apply registry names/cardinality bounds. Add the Actuator API as `compileOnly` plus
`testImplementation` and register the adapter's health bridge only when Actuator is present and the
capability is enabled; the runtime consumer already supplies Actuator. The objectstorage leaf must
not depend on `sample-portfolio`. Do not add an `app-bootstrap` project edge.
- [ ] **Step 7: Verify GREEN and locks**
Run:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
git diff -- adapter/outbound/objectstorage/gradle.lockfile
./gradlew \
:adapter:outbound:objectstorage:test \
:adapter:outbound:objectstorage:objectStorageResourceTest \
:adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
```
Expected: only the reviewed Micrometer/Actuator/resource-source-set lock delta, then PASS.
### Task 29: Run protected AWS security/fault qualification and promote exact cards only
**Files:**
- Modify:
`src/adapter/outbound/objectstorage/build.gradle`
- Modify:
`src/adapter/outbound/objectstorage/gradle.lockfile`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageSecurityTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3SecurityQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ResponseLossQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3VersionRetentionQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3CredentialLifecycleQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ProcessCrashQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3BackupRestoreReconciliationQualificationTest.java`
- Create:
`src/adapter/outbound/objectstorage/src/test/resources/object-storage/aws-provider-evidence.json`
- Modify:
`.github/workflows/object-storage-qualification.yml`
- Modify:
`.github/ci-gate-matrix.yml`
- Modify:
`.github/scripts/verify-gate-matrix.sh`
- Modify:
`docs/registries/object-storage-readiness.yaml`
- [ ] **Step 1: Register protected, non-skipping tasks**
`objectStorageSecurityTest` and `objectStorageAwsQualificationTest` must require the approved
profile and exact attestation. Missing Docker/AWS/IaC/credential inputs fail when the lane is
selected. Never put account IDs, credentials, KMS material, or signed URLs in reports.
Regenerate/review locks for the protected configurations and verify the gate matrix before any
external call:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
cd ..
bash .github/scripts/verify-gate-matrix.sh
```
The protected workflow is manual/release-gated behind an approved environment and executes the
managed/common test from Task 16, direct test from Task 19, and all Phase 6 tests. Scheduled MinIO
fault remains separate. Retained artifacts are normalized card/result/evidence digests with expiry,
never secrets or raw topology.
- [ ] **Step 2: Execute the full matrix**
The exact AWS provider/profile/card matrix must include, as relevant:
- real TLS/network/expected-owner/workload-role and credential refresh/rotation;
- BPA/ownership/private ACL negative tests;
- SSE-S3/SSE-KMS/DSSE profile and KMS deny/throttle/disabled-key behavior;
- conditional data/control mutation, checksum, versioning propagation soak/sentinel, delete
markers, and noncurrent exact purge;
- lifecycle/incomplete multipart; when and only when the retention card is proposed, mandatory
Object Lock, exact retention/legal-hold reads, normal-role governance-bypass denial, privileged
exact-version purge, and a generic-403 negative test proving it is not mapped to `HELD`;
- grant expiry versus credential horizon/qualified skew and an IaC-enforced bounded
`s3:signatureAge` positive/negative test; unhealthy time blocks new grant admission;
- DNS/connect/TLS/acquire/read/write/API deadline and SDK retry amplification;
- throttle, permission revocation, response drop, process halt/restart, rolling control schema;
- managed/direct single/multipart, download/range, quarantine only if a real scanner exists,
retention, and reconciliation card-specific requirements;
- bounded export/restore of exact data, manifests, reference pointers, operation/control records,
and version evidence into the authorized disposable restored namespace, followed by
reconciliation and digest/reference integrity audit;
- observability/redaction and no silent skip.
- [ ] **Step 3: Run protected commands**
Run in the authorized lane:
```bash
cd src
./gradlew \
:adapter:outbound:objectstorage:objectStorageSecurityTest \
:adapter:outbound:objectstorage:objectStorageAwsQualificationTest \
:adapter:outbound:objectstorage:objectStorageResourceTest \
--console=plain
```
Expected: PASS only for the exact tested combinations. A partial failure leaves that card/profile at
its previous level; it does not lower requirements or borrow evidence from another card.
Regional/cluster DR is not inferred from the bounded restored-namespace test.
- [ ] **Step 4: Update signed evidence and readiness registry**
For each promoted row record:
```text
card_id
provider exact type/version/deployment identity
destination profile
R2
evidence revision and expiry
required non-skipping task names/results
limitations
attestation digest
```
Do not write a module-global R2 statement. In particular, scanner absence keeps
`object-storage-quarantine-publication` below R2. All direct upload/download cards remain below R2
even if provider qualification passes because this plan implements no public direct endpoint,
authorization/rate limiting, or direct API snapshot. A reconciliation row may reach R2 only when
the bounded backup/restore qualification passes; retention may reach R2 only when every mandatory
Object-Lock/role/negative test above passes.
### Task 30: Complete documentation, full gates, independent review, and Wiki capture
**Files:**
- Create:
`docs/runbooks/object-storage-startup-qualification-failed.md`
- Create:
`docs/runbooks/object-storage-managed-transfer-failed.md`
- Create:
`docs/runbooks/object-storage-indeterminate-operation.md`
- Create:
`docs/runbooks/object-storage-checksum-mismatch.md`
- Create:
`docs/runbooks/object-storage-scan-backlog.md`
- Create:
`docs/runbooks/object-storage-multipart-abandonment.md`
- Create:
`docs/runbooks/object-storage-orphan-backlog.md`
- Create:
`docs/runbooks/object-storage-retention-hold.md`
- Create:
`docs/runbooks/object-storage-credential-kms-failure.md`
- Create:
`docs/runbooks/object-storage-direct-grant-cors-incident.md`
- Create:
`docs/runbooks/object-storage-control-corruption-schema.md`
- Create:
`docs/runbooks/object-storage-cleanup-delete-kill-switch.md`
- Create:
`docs/runbooks/object-storage-local-filesystem-capacity.md`
- Create:
`docs/runbooks/object-storage-poster-handoff-stuck.md`
- Create:
`docs/runbooks/object-storage-epoch-compaction.md`
- Create:
`docs/runbooks/object-storage-backup-restore-reconciliation.md`
- Create:
`docs/runbooks/object-storage-provider-outage-upgrade.md`
- Create:
`docs/runbooks/object-storage-readiness-downgrade.md`
- Modify:
`src/adapter/outbound/objectstorage/README.md`
- Modify:
`src/adapter/outbound/objectstorage/CLAUDE.md`
- Modify:
`src/sample-portfolio/README.md`
- Modify:
`docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md`
- Modify:
`docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
- Modify:
`docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md`
- Modify, only for actual fixed runtime placeholders:
`src/app-bootstrap/src/main/resources/application.yml`,
`src/.env`, `docs/registries/env-keys.yaml`,
`docs/registries/secrets-classification.yaml`
- Modify, when surfaced externally:
`docs/registries/error-codes.yaml`, `docs/registries/headers.yaml`
- [ ] **Step 1: Write and exercise runbooks**
Each runbook must include detection, scope, safe first actions, evidence to preserve, report-only
and admission-disable controls, exact reconciliation commands, escalation, and unsafe actions.
Exercise startup mismatch, credential expiry, KMS deny, response loss, checksum mismatch, scan
backlog, multipart/orphan cleanup, and retention hold in the relevant non-skipping lane.
Maintain this design §40 traceability and exercise every row required by a claimed R2 card:
| Incident family | Runbook owner |
| --- | --- |
| startup attestation/evidence mismatch, readiness downgrade | `startup-qualification-failed`, `readiness-downgrade` |
| TLS/certificate/DNS/VPC endpoint/pool saturation | `startup-qualification-failed`, `managed-transfer-failed`, `provider-outage-upgrade` |
| managed timeout/response loss/checksum | `managed-transfer-failed`, `indeterminate-operation`, `checksum-mismatch` |
| presigned URL leak/reissue/signature age/CORS drift | `direct-grant-cors-incident` |
| control corruption/newer schema/rolling reader | `control-corruption-schema` |
| scan backlog/malicious/indeterminate | `scan-backlog` |
| multipart/orphan/late grant | `multipart-abandonment`, `orphan-backlog` |
| cleanup DELETE enablement/process kill/stale fence | `cleanup-delete-kill-switch` |
| retention/legal hold/privileged purge | `retention-hold` |
| versioning suspended/delete-marker or noncurrent-version growth | `retention-hold`, `readiness-downgrade` |
| credential expiry/rotation and KMS deny/throttle | `credential-kms-failure` |
| local filesystem disk/inode/permission | `local-filesystem-capacity` |
| UploadIntent/handoff/retirement stuck | `poster-handoff-stuck` |
| epoch seal/compaction/replay expiry | `epoch-compaction` |
| bounded backup/restore reconciliation | `backup-restore-reconciliation` |
| provider outage/upgrade/rollback | `provider-outage-upgrade` |
| graceful shutdown/in-flight drain/resource leak | `managed-transfer-failed`, `readiness-downgrade` |
| operation/reference lookup hot partition or prefix imbalance | `provider-outage-upgrade`, `readiness-downgrade` |
- [ ] **Step 2: Reconcile settings/env/secrets truthfully**
Because `app-bootstrap` has no objectstorage project edge in this plan, do not add orphan
object-storage placeholders to its YAML or `src/.env`. Protected qualification inputs belong to CI
secret/config, not application env registries. If a future approved production owner adds the edge,
that separate plan must update `modules.json`, `app-bootstrap/build.gradle`, application YAML,
`.env`, env/secrets registries, settings tests, and `verifyEnvKeys` together.
Document canonical settings and explicit sample-local YAML without inventing static production
credentials. Update error/header registries only for fields actually exposed by the approved sample
API.
- [ ] **Step 3: Run focused and integration gates**
Run:
```bash
cd src
./gradlew :application-core:check --console=plain
./gradlew :adapter:outbound:persistence-jpa:check --console=plain
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :sample-portfolio:check --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew :app-bootstrap:sampleOffTest --console=plain
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
:adapter:outbound:objectstorage:objectStorageSecurityTest \
:adapter:outbound:objectstorage:objectStorageAwsQualificationTest \
:adapter:outbound:objectstorage:objectStorageResourceTest \
--console=plain
```
Expected: all selected required tasks PASS; no selected readiness task skips.
Assert the `posterImageMigrationTest` XML has zero skipped tests.
- [ ] **Step 4: Run repository-wide gates**
Run:
```bash
cd src
./gradlew test --console=plain
./gradlew check --console=plain
./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --console=plain
./gradlew \
:application-core:verifyDependencyLocks \
:adapter:outbound:objectstorage:verifyDependencyLocks \
:sample-portfolio:verifyDependencyLocks \
:app-bootstrap:verifyDependencyLocks \
verifyCleanArchitectureDependencies \
verifyPublicPathSnapshot \
verifyEnvKeys --console=plain
cd ..
bash .github/scripts/verify-gate-matrix.sh
git diff --check
```
Expected: all commands PASS. If an unrelated dirty-file check fails, preserve it, report exact
ownership/evidence, and do not claim the repository-wide gate passed.
- [ ] **Step 5: Perform independent review**
Review at least these tracks independently:
1. application/transport/domain boundary and module edges;
2. operation fingerprint, state machine, CAS, response-loss and crash-gap recovery;
3. provider exactness, checksum/multipart/presign/version/retention semantics;
4. configuration, credentials, TLS, attestation, redaction, resource/lifecycle safety;
5. sample DB/API migration, rollback, dual read, retirement;
6. readiness claims versus actual non-skipping evidence.
Resolve every blocker/high (or Critical/Important) finding and rerun affected focused plus full
gates.
- [ ] **Step 6: Capture the required LLM Wiki record**
Before the implementation completion response, read the canonical vault instructions and update:
```text
/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/<branch-name>.md
```
Record files, decisions, test commands/results, failures/blocks, evidence grade, and derivative
raw notes with bidirectional links. If the canonical vault is absent or inaccessible, do not use a
different clone; record the exact block in the plan/design and final response.
- [ ] **Step 7: Report exact completion**
The handoff must list changed files, implementation slices, exact commands/results, failures or
unrun protected lanes, Wiki capture, card/provider/profile/evidence tuples, rollback posture, and
remaining gates. Never use “Objectstorage R2” as an unqualified completion statement.
---
## Phase 7 follow-up plan, not executable here
Create a separately approved plan only after Phase 6 evidence exists. It must cover:
- multi-node operation/maintenance fencing and failover;
- rolling writer/reader schema compatibility and old binding/policy restoration;
- regional/cluster disaster-recovery game day and failover restore integrity audit, building on but
not replaced by Phase 6's bounded single-destination restored-namespace R2 test;
- sustained load, quota/capacity exhaustion, credential/KMS rotation under load;
- provider upgrade/rollback and operational game day;
- a split/no-split ADR based on actual dependency/release/security/runtime divergence.
Until that plan passes, no exact card may claim R3. Disabling optional cards and returning new
admission to the last qualified provider/schema revision is the rollback posture; live operation,
reference, session, and audit records remain readable/reconcilable.