177 KiB
Object Storage Production Capability Implementation Plan
- 작성일: 2026-07-28
- 상태: 구현 계획 작성 완료, 모든 task 미착수, R2 미구현
- 범위: 상세 설계 Phase 0–6; Phase 7/R3는 별도 승인 계획
- LLM Wiki capture: 정본 vault
/home/donghyeon/workspace/ai-tool/llm-wiki-private/부재로 차단; 비정본 clone 대체 사용 안 함
For agentic workers: REQUIRED SUB-SKILLS: use
superpowers:subagent-driven-developmentto execute independent tasks,superpowers:test-driven-developmentfor every behavior change, andsuperpowers:verification-before-completionbefore changing any readiness claim. Track progress with the checkboxes in this document. Repository policy ishuman-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.
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 0–6. 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.
Execution rules
- Execute batches in order. A later batch may start only after the preceding checkpoint passes.
- Within a task, write the named failing test first, run the stated RED command, make the minimum implementation, then run the same command GREEN.
- 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.
- Preserve the legacy port as an isolated compatibility seam. New business code must never import
dev.caskeleton.application.storage. - Do not expose AWS SDK, Spring,
Path, bucket, raw key, provider endpoint, raw ETag, upload ID, persistent provider locator, or inbound DTO types throughapplication-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-onlydev.caskeleton.application.storage.migrationadoption 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. - Do not call a producer while a database transaction is open. Do not claim that a database rollback reverses an object mutation.
- Unsupported provider behavior is a typed rejection or startup failure, never a fallback, check-then-overwrite emulation, test skip, or weaker silent guarantee.
filesystem-local-devand 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.- Run Spotless only on files changed by this plan. Do not format or rewrite unrelated dirty files.
- 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.
- Every Batch A–F 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
Batch A: Phase 0–1 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 0–1: 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 -
Step 1: Record current executable behavior
Prove the current contract exactly as-is:
-
repeated
putto the same caller key overwrites; -
getmaterializes the whole object; -
filesystem returns
file://and S3 returnss3://; -
absent
ca-skeleton.objectstorage.backendcreates a filesystem bean and directory during application-context construction, before the firstput; -
S3
autoCreateBucket=truecan provision at startup; -
Poster calls storage while
TransactionPort.inWriteis active; -
the controller calls
MultipartFile.getBytes; -
the response exposes raw key/location;
-
Poster deletion does not retire the object.
-
Step 2: Verify the characterization baseline
Run:
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.
- Step 3: Inventory runtime and data dependencies
In the evidence document record command output, not an unsupported repository-wide conclusion:
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.
- Step 4: Run unchanged focused suites
Run:
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 -
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:
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.
- 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.
- Step 3: Verify RED
Run:
cd src
./gradlew :application-core:test \
--tests '*ObjectStorageIdentityContractTest' --console=plain
Expected: compilation failure because the new identity types do not exist.
- 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.
- 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 -
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.
-
Step 2: Verify RED
Run:
cd src
./gradlew :application-core:test \
--tests '*ObjectContentContractTest' \
--tests '*ObjectStorageValueContractTest' --console=plain
Expected: compilation failure because the callback/value types do not exist.
- 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.
- 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 -
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;
-
StagedObjectPublicationPorthas no scan-read/verdict methods;ObjectScanMaintenancePortalone 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.
-
Step 2: Verify RED
Run:
cd src
./gradlew :application-core:test \
--tests '*ObjectStoragePortContractTest' --console=plain
Expected: compilation failure because the semantic port family does not exist.
- 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.
- 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 -
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.
- Step 2: Verify RED
Run:
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.
- 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.
- 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.
- Step 5: Run Batch A checkpoint
Run:
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 -
Step 1: Add the approved test-only property engine
Add testImplementation 'net.jqwik:jqwik:1.9.1' and update only this leaf's lockfile:
cd src
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
Expected: PASS with no production jqwik dependency.
- Step 2: Write failing codec and epoch tests
Use golden vectors and property tests for:
-
data/v1and everycontrol/v1grammar 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-nameString; 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/osm1grammar 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 -> COMPACTEDtransitions, no token reuse, andOPERATION_EXPIREDafter seal/compaction. -
Step 3: Verify RED
Run:
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.
- 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.
- 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 -
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.
- 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
INDETERMINATEuntil resolution; -
scan, published-reference, direct-session, and multipart states remaining independent;
-
stale fence/revision rejection;
-
unknown/newer state never auto-deleted or downgraded.
-
Step 3: Verify RED
Run:
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.
- 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.
- 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 -
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.
- 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.
-
Step 3: Verify RED
Run:
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.
- 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.
- 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 -
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.
- Step 2: Write failing composition tests
Use ApplicationContextRunner to prove:
-
absent or
enabled=falsecreates 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;
-
matchIfMissingis 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
legacysubgroup plus the new capability may run together only in the explicit migration/sample-local profile. -
Step 3: Verify RED
Run:
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.
- 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.
- Step 5: Verify GREEN
Run the command from Step 3. Expected: PASS.
- 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 -
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.
-
Step 2: Verify RED
Run:
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
Expected: failure because the registry and runtime card types do not exist.
- 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 6–9 pass. Direct, multipart, quarantine, retention, and production reconciliation remain R0/unimplemented. Document that local process recovery is not multi-node CAS or R2.
- Step 4: Run Batch B checkpoint
Run:
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.
- 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 -
Step 1: Write the failing pinned-API characterization
Compile and assert the exact 2.30.0 API used by later tasks:
PutObjectRequest.Builder.ifNoneMatchandifMatch;CompleteMultipartUploadRequest.Builder.ifNoneMatch,ifMatch, andmpuObjectSize(Integer);- explicit request/response checksum configuration;
expectedBucketOwneron 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.
- 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.
- Step 3: Verify RED
Run:
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.
- 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.
- Step 5: Regenerate and verify locks
Run:
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:
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, orRequestBody.fromBytespath 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:
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;
-
404is 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:
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:
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:
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:
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:
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:
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:
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:signatureAgebound, or at/aftercredential/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-ceilingprofile 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:
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:
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_PROGRESSplus 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 remainsINDETERMINATE/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_PROGRESSCAS; -
conditional complete is followed by exact final version/size and
FULL_OBJECTSHA-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;
-
409complete 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_PROGRESSCAS. -
Step 3: Verify RED
Run:
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:
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:
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:
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
Do not execute Tasks 20–24 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-referenceor an explicitly named scan-gated profile with a real scanner owner; - the additive endpoint proposal
POST /posters/{id}/image-publications, status, authorization, request size/media policy, and locator-free response fields; - the asynchronous response contract: POST always returns the same bounded
202reservation (opaque publication operation plus status link) replayed byIdempotencyExecutor; 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 committedUploadIntentallocates/reuses the stableObjectOperationId; - the exact
IdempotencyExecutorshape: inside one TX1 it atomically claims/completes the stable202reservation 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}/imageendpoint; - a consumer inventory and owner approval for the existing
poster.image-attachedbroker 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 renameimageKeytoreferenceunder the same unversioned event contract; - intentional OpenAPI snapshot approval;
- the additive V7 schema and forward-only rollback window.
- the compatibility model during V7: 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; -
MALICIOUSandINDETERMINATEfail 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
ObjectIdand 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_AUTHORIZEDand then issues an exactObjectAbortAuthorization; -
late finalize versus abort authorization;
-
missing application intent never triggers auto-delete;
-
object without matching intent is quarantined/reported.
-
Step 3: Verify RED
Run:
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/V7__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/PosterImageV7MigrationQualificationTest.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:
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 V1–V7 and V6→V7 upgrade;
- V6 is unchanged;
- existing
poster.image_keyrows 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;
SUPERSEDEDis 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_intentrow and cannot be inferred from a deleted Poster or overloaded upload intent.
The V7 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:
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 V7, 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:
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 PosterImageV7MigrationQualificationTest must run a V6-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:
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:
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
V7 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:
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.javatosrc/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.javatosrc/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
MultipartFileinto 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
202only 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:writeis 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:
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:
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:
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:
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:
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:
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 V7
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 26–29.
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:
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:signatureAgepolicy, 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, maximums3: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:
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; -
HELDrequires exact successfulGetObjectRetention/GetObjectLegalHoldevidence; a generic403, timeout, unavailable API, or unmapped provider error isUNKNOWN/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-retentionis 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:
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:
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:
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:
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:
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:
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:signatureAgepositive/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:
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:
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:
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:
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:
- application/transport/domain boundary and module edges;
- operation fingerprint, state machine, CAS, response-loss and crash-gap recovery;
- provider exactness, checksum/multipart/presign/version/retention semantics;
- configuration, credentials, TLS, attestation, redaction, resource/lifecycle safety;
- sample DB/API migration, rollback, dual read, retirement;
- 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:
/home/donghyeon/workspace/ai-tool/llm-wiki-private/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.