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

179 KiB
Raw Blame History

Object Storage Production Capability Implementation Plan

  • 작성일: 2026-07-28
  • 상태: Batch AD 및 Batch E pause checkpoint를 main에 통합, Batch E 전체 gate와 R2는 미완료
  • 범위: 상세 설계 Phase 06; Phase 7/R3는 별도 승인 계획
  • LLM Wiki capture: public vault의 raw/branch-notes/chore-main-worktree-capability-integration.md에 main 통합 기록 완료

For agentic workers: REQUIRED SUB-SKILLS: use superpowers:subagent-driven-development to execute independent tasks, superpowers:test-driven-development for every behavior change, and superpowers:verification-before-completion before changing any readiness claim. Track progress with the checkboxes in this document. Repository policy is human-only: do not stage, commit, amend, or push.

Goal: Replace the caller-keyed, whole-object byte[] example with a framework-free, bounded-streaming object publication capability, migrate the sample Poster workflow through a durable database/object-storage handoff, and qualify only exact provider/card combinations for which the required evidence exists.

Architecture: application-core owns provider-neutral identities, content callbacks, requests, receipts, outcomes, and narrow outbound ports under dev.caskeleton.application.objectstorage. adapter:outbound:objectstorage owns immutable data/control namespaces, canonical codecs, operation state machines, provider bindings, filesystem/S3 implementations, reconciliation, and readiness evidence. The legacy dev.caskeleton.application.storage CRUD contract remains isolated until the sample and stored data have migrated. sample-portfolio owns the business UploadIntent, database attachment choreography, and public HTTP contract; the object-storage adapter never reads the sample database.

Tech Stack: Java 21, Spring Boot 4.0.0 configuration properties/autoconfiguration, Gradle multi-module verification, JUnit 5, AssertJ, jqwik 1.9.1, AWS SDK for Java v2 2.30.0, S3AsyncClient with the Java Netty NIO HTTP client, Testcontainers 2.0.2, version-pinned MinIO, Toxiproxy, PostgreSQL/Flyway for the sample workflow, Micrometer/Actuator.

Authoritative design: Object Storage Production Capability Deep Design. When this plan and that design differ, stop and amend the design before implementing. Module edges remain governed only by src/config/architecture/modules.json.

Scope boundary: This is the executable master plan for design Phases 06. It deliberately does not claim that all tasks belong in one pull request or release. Phase 7 multi-node/R3 work, a provider-leaf split, a production malware-scanner implementation, and browser API product choices outside the approved contracts require follow-up plans. Writing this plan changes no runtime behavior and advances no readiness card.

Implementation checkpoint (2026-07-28): The Batch AB application/kernel/local-provider baseline and the Batch CD S3/MinIO managed/direct primitive slices are implemented and verified in codex/objectstorage-production-capability. Exact MinIO qualification proved that create-only PUT and multipart completion conditions are ignored, so its managed/direct mutation profiles remain UNSUPPORTED. AWS lanes compile only and have no observed evidence. Only the exact local managed single upload/download cards remain R1; every direct, multipart, quarantine, retention, and production reconciliation card remains R0. The exhaustive R2 fault/race matrices named by unchecked steps remain follow-up evidence, not implied completion. The legacy provider and Poster consumer remain isolated behind explicit sample-local/test activation. Evidence: Batch A, Batch B, and Batch C, Batch D.

Batch E의 개별 focused fix 증거는 Batch E pause checkpoint에 기록됐다. 당시 Batch E combined/root gate는 재실행되지 않았으므로 Batch E 전체 완료나 R2를 주장하지 않는다. 격리 브랜치에서 잘못 선택했던 private vault 경로 대신, main 통합 작업은 사용자가 지정한 public vault의 raw/branch-notes/chore-main-worktree-capability-integration.md에 캡처했다.


Execution rules

  1. Execute batches in order. A later batch may start only after the preceding checkpoint passes.
  2. Within a task, write the named failing test first, run the stated RED command, make the minimum implementation, then run the same command GREEN.
  3. A compilation failure counts as RED only when the missing symbol is the symbol the task is intentionally introducing. Dependency, formatting, daemon, Docker, credential, or unrelated compilation failures do not count.
  4. Preserve the legacy port as an isolated compatibility seam. New business code must never import dev.caskeleton.application.storage.
  5. Do not expose AWS SDK, Spring, Path, bucket, raw key, provider endpoint, raw ETag, upload ID, persistent provider locator, or inbound DTO types through application-core. A bounded presigned URI exists only in the explicit transient direct-grant value, is always redacted, and is never persisted in a receipt/control record. The only raw-locator exception is the exact, deprecated, admin-only dev.caskeleton.application.storage.migration adoption seam in Task 24; its value is bounded, redacted, never logged/serialized into a receipt, and may not be injected into a normal business use case.
  6. Do not call a producer while a database transaction is open. Do not claim that a database rollback reverses an object mutation.
  7. Unsupported provider behavior is a typed rejection or startup failure, never a fallback, check-then-overwrite emulation, test skip, or weaker silent guarantee.
  8. filesystem-local-dev and the pinned MinIO topology have an R1 ceiling. AWS S3 also remains below R2 until the Phase 6 protected qualification lane passes for an exact provider, destination profile, card, and evidence revision.
  9. Run Spotless only on files changed by this plan. Do not format or rewrite unrelated dirty files.
  10. At every batch boundary, update the implementation-status section in the design and this plan. Do not mark a task complete from code inspection alone.
  11. Every Batch AF checkpoint is a meaningful-work capture boundary. Re-read the canonical LLM Wiki instructions, update the branch note and any honest derivatives, or record the exact canonical-vault access block in both this plan and the design before starting the next batch. A final Task 30 capture does not replace these per-batch records.

Frozen implementation decisions

These decisions translate §37 of the design into executable constraints. A row marked approval gate is intentionally not delegated to an implementation task.

Concern Decision for this plan
Application package New contract lives under dev.caskeleton.application.objectstorage with identity, content, model, request, and port subpackages.
Legacy coexistence dev.caskeleton.application.storage.ObjectStoragePort and StoredObject become deprecated legacy-only types. They remain readable until sample data migration, dual-read observation, API snapshot approval, and zero production usages are evidenced.
Control format canonical-json-v1: UTF-8, fixed field order, no insignificant whitespace, decimal integers, canonical enum names, strict duplicate/unknown-field rejection, bounded record-family sizes, and an outer SHA-256 corruption digest. R2 relies additionally on private namespace/IAM and provider encryption; the digest is not described as tamper authentication.
Record sizing Operation/reference/session records are at most 64 KiB; terminal receipts at most 16 KiB; multipart part receipts are separate immutable records of at most 4 KiB each. A 10,000-part ledger is never materialized into one control object.
Conditional CAS S3 uses PutObject If-None-Match: * for reserve and exact private ETag If-Match for revision CAS. The pinned SDK API must be characterized before use. MinIO must prove the same semantics in a non-skipping integration test. Filesystem local-dev uses process lock plus exclusive create/atomic replace and therefore stays R1.
Public reference osr1.<route>.<object>.<check> where route is 12 lowercase Crockford Base32 characters, object is 26 lowercase Crockford Base32 characters (128 random bits), and check is the first 10 lowercase hex characters of SHA-256 over the first three components. Total parsing is bounded; the value contains no provider locator and is not authorization.
Stage/session handles Separate prefixes (osh1, osu1, osm1) and the same bounded route/random/check structure. A stage or session handle is rejected by published-reference parsers and public read ports.
Operation deadline Reuse dev.caskeleton.application.outbound.CallBudget as the absolute monotonic parent budget. Add a framework-free CancellationView; never serialize either value into durable control records. Persist only wall-clock attempt/lease timestamps and bounded policy durations.
Async S3 HTTP Use S3AsyncClient with an explicitly configured NettyNioAsyncHttpClient. Do not use CRT, S3TransferManager, or SDK-owned automatic multipart for an R2 card.
AWS SDK Keep the repository SSOT awsSdkVersion = 2.30.0 for this plan. Characterize checksum defaults, conditional builders, and the mpuObjectSize(Integer) boundary. A version upgrade needs its own dependency/evidence decision.
Local-dev root No code default. Unit tests use @TempDir; the explicit sample-local profile may bind ./.data/object-storage-v1. Production profiles reject filesystem-local-dev.
MinIO identity Initial test identity is s3-compatible-minio-community-release-2024-01-16t16-07-38z, corresponding to the existing minio/minio:RELEASE.2024-01-16T16-07-38Z. Phase 3 acceptance requires conversion to an image digest pin and records that digest as provider-version evidence.
Provider IDs Only filesystem-local-dev, aws-s3-general-purpose, and the exact MinIO identity above are implemented in this plan. filesystem-local-persistent remains a follow-up provider.
Binding prefix Canonical prefix is app.object-storage; enabled defaults to false, and there is no default provider or destination. Any simultaneous ca-skeleton.objectstorage.* and canonical configuration fails startup without logging values.
Composition SPI Task 9 creates a side-effect-free provider-contribution registry. Settings are fully compiled before the assembler asks only selected contributions to construct clients. Normal semantic routers, scan-maintenance routers, and privileged purge routers are distinct concrete types/configurations; no catch-all router exposes a privileged port in a normal application context. Every later provider slice updates its explicit contribution and selected/unselected/disabled composition test—component scanning is not provider activation.
Bootstrap edge Do not add an app-bootstrap -> adapter-outbound-objectstorage edge in this plan: there is no production use-case owner there. The sample already has the registered runtime-only edge. A future production owner requires a separate registry/Gradle approval.
Scanner owner Application contracts and staged scan fencing are implemented; tests use a fake scanner verdict source. Selecting and implementing a production scanner is an approval gate and is required before the quarantine-publication card can claim R2.
Sample durable work Use a dedicated poster_image_upload_intent table/repository and reconciliation use case. Do not reuse broker-delivery outbox rows as an object-storage operation journal. Object bytes never enter that table.
API compatibility Preserve the existing /posters/{id}/image response during the legacy window. A new opaque-reference response and any upload-session endpoints require an approval gate plus intentional OpenAPI snapshot regeneration; no task silently removes key/location.
Direct-card ceiling This plan implements provider/session primitives but no approved public direct-upload/download/session endpoint. Therefore direct single, direct multipart, and direct-download cards remain at most R1/partial and Task 29 may not promote them to R2. A follow-up inbound authorization/rate-limit/API snapshot plan is required.
Readiness registry Add docs/registries/object-storage-readiness.yaml, schema version 1, with exact card_id, provider type/version, destination profile, claimed level, evidence revision/expiry, required non-skipping Gradle tasks, and limitations. Runtime descriptors are derived from compiled binding plus live qualification; the registry is the CI claim manifest, not a substitute for probes.
Cleanup ownership Maintenance lease is (destination route, job id, owner token, monotonically increasing fence, expires-at). Every destructive cleanup also needs an exact object/version precondition and an application handoff/abort authorization. Age or LIST absence alone never authorizes deletion. Report-only is the default.
Phase 7 Phase 6 must still prove bounded backup/restore reconciliation for every exact R2 reconciliation card in a disposable namespace. Regional/cluster disaster-recovery game days, multi-node failover/fencing, sustained scale, and the provider-leaf split/no-split ADR are excluded and require a new approved plan.

Batch graph and promotion gates

Batch A: Phase 01 contract
  -> Batch B: Phase 2 provider-neutral kernel + local R1
  -> Batch C: Phase 3 managed S3/MinIO common subset
  -> Batch D: Phase 4 direct transfer + multipart
  -> Approval Gate A: scanner owner + sample API contract
  -> Batch E: Phase 5 staged publication + sample migration
  -> Approval Gate B: AWS sandbox/IaC/workload identity
  -> Batch F: Phase 6 exact R2 security/maintenance/readiness
  -> separate Phase 7 plan
Checkpoint Minimum acceptance Rollback posture
A Application contracts are pure; legacy behavior is characterized; no provider types leak. Keep new binding disabled and old consumer unchanged.
B Local-dev passes bounded provider contract and restart characterization; only R1 is published. Disable canonical capability; legacy local example remains isolated.
C Async managed path, CAS, response-loss resolution, and adapter-owned multipart pass pinned MinIO; the AWS lane is compile-only/authority-pending with no observed evidence. Rebind only new destinations to the prior qualified provider; old route revisions stay readable.
D Direct grant/session ledgers pass expiry, redaction, completion, and late-request races; no public endpoint is implied. Stop issuing grants, drain/expire/reconcile existing sessions, keep managed transfer.
E Database intent precedes remote mutation; every crash gap is tested; no unscanned data becomes public; old locators remain dual-readable only during migration. Stop new admission, drain intents, preserve published-reference reads, do not delete legacy data.
F Each claimed card has exact non-skipping security/fault/real-provider evidence and runbooks. Disable card admission or maintenance delete, retain published reads and the manual reconciliation queue.

Every checkpoint named below has three inseparable outputs: (1) the stated GREEN commands and zero-selected-skip evidence, (2) updated plan/design implementation status and truthful readiness rows, and (3) a canonical LLM Wiki branch-note update or the exact canonical-vault access block. This applies to A at Task 5, B at Task 10, C at Task 16, D at Task 19, E at Task 24, and F at Task 30; a batch is not closed if any output is missing.


Batch A — Phase 01: Truth and framework-free contract

Task 1: Characterize the legacy boundary without changing behavior

Files:

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java

  • Create: docs/evidence/object-storage/phase-0-inventory.md

  • Step 1: Record current executable behavior

Prove the current contract exactly as-is:

  • repeated put to the same caller key overwrites;

  • get materializes the whole object;

  • filesystem returns file:// and S3 returns s3://;

  • absent ca-skeleton.objectstorage.backend creates a filesystem bean and directory during application-context construction, before the first put;

  • S3 autoCreateBucket=true can provision at startup;

  • Poster calls storage while TransactionPort.inWrite is active;

  • the controller calls MultipartFile.getBytes;

  • the response exposes raw key/location;

  • Poster deletion does not retire the object.

  • 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;

  • StagedObjectPublicationPort has no scan-read/verdict methods; ObjectScanMaintenancePort alone owns unpublished exact-version transfer and verdict recording, and neither normal publication nor purge port is assignable to it;

  • receipts expose no locator, ETag, upload ID, URL, credential, or provider enum;

  • requirements can strengthen but never lower destination policy.

  • 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/v1 and every control/v1 grammar in design §10;

  • ASCII-only segments, fixed maximum segment/total length, deterministic shard, and rejection of slash aliases, percent encoding, Unicode normalization ambiguity, ./.., and control characters;

  • data-key APIs accept only generated ObjectId, route, generation, and typed revision values, never a filename/tenant/raw-name String; compile/static contract tests prove that representative email/filename values have no accepted parameter path instead of attempting a PII heuristic;

  • the exact osr1/osh1/osu1/osm1 grammar frozen above;

  • canonical fingerprint field order, absence-versus-empty, integer overflow, enum names, and schema version;

  • same intent yielding the same fingerprint and any semantic field change yielding a different fingerprint;

  • binding/policy snapshots that contain no secret or provider credential;

  • epoch WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED transitions, no token reuse, and OPERATION_EXPIRED after seal/compaction.

  • 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 INDETERMINATE until resolution;

  • scan, published-reference, direct-session, and multipart states remaining independent;

  • stale fence/revision rejection;

  • unknown/newer state never auto-deleted or downgraded.

  • 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=false creates zero ports, directory, credential lookup, client, thread, scheduler, health indicator, and warning;

  • enabled explicit local binding creates exactly one routing implementation for each applicable semantic port;

  • an unknown destination fails before producer invocation;

  • routing retains old route/binding revisions for reads/reconciliation;

  • matchIfMissing is gone;

  • legacy-only mode remains isolated and opt-in during migration;

  • an old ca-skeleton.objectstorage.* alias together with any canonical setting fails without logging property values;

  • a namespace-separated canonical legacy subgroup plus the new capability may run together only in the explicit migration/sample-local profile.

  • 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 69 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.ifNoneMatch and ifMatch;
  • CompleteMultipartUploadRequest.Builder.ifNoneMatch, ifMatch, and mpuObjectSize(Integer);
  • explicit request/response checksum configuration;
  • expectedBucketOwner on every relevant request;
  • presigner availability without constructing it yet.

Add boundary tests at Integer.MAX_VALUE, Integer.MAX_VALUE + 1L, part count 10,000/10,001, and minimum S3 non-final part size. Record the full-object multipart checksum profile as unsupported above the SDK integer boundary unless an independently approved path exists.

  • 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, or RequestBody.fromBytes path exists.

  • Step 2: Write failing provider tests

Mock only the SDK boundary and prove exact request mapping:

  • immutable single PUT uses If-None-Match: *, exact content length/checksum/encryption/owner;

  • HEAD validates size, version, checksum, encryption, and publication record;

  • GET/range validates returned version, range, content length/range, and digest mode;

  • empty object is valid;

  • 409/412, permission, absence, throttling, and response loss map to distinct outcomes;

  • public receipts contain no S3 locator/evidence.

  • the selected S3 contribution exposes managed single/inspect/download/range delegates through the normal routers, while unselected/disabled contexts still expose none and create no resources.

  • Step 3: Verify RED

Run:

cd src
./gradlew :adapter:outbound:objectstorage:test \
  --tests '*S3AsyncRequestBodyBridgeTest' \
  --tests '*S3AsyncResponseBodyBridgeTest' \
  --tests '*S3ManagedObjectProviderTest' \
  --tests '*S3ProviderCompositionTest' --console=plain

Expected: compilation failure because the async bridges/provider do not exist.

  • Step 4: Implement the minimum managed path

Bridge the synchronous application callbacks through a bounded adapter-owned worker and queue. Propagate cancellation to the SDK future/body, invalidate callback resources, and release every semaphore/buffer. Do not let SDK retry replay a non-repeatable producer. If transport retry requires body replay, resolve evidence or require a new operation; an adapter spool needs a separate plan.

  • Step 5: Verify GREEN

Run the command from Step 3. Expected: PASS.

Task 14: Implement S3 conditional control storage and response-loss resolution

Files:

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java

  • Modify: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java

  • Step 1: Write failing CAS tests

Prove exact-key direct lookup and:

  • reserve with If-None-Match: *;

  • update with the record's exact private ETag in If-Match;

  • stale writer and same-operation/different-fingerprint conflict;

  • dropped create/update response reconciled by GET and record digest/revision comparison;

  • 404 is authoritative only after the operation-specific evidence rules permit it;

  • corrupt/newer record never becomes absent or overwritten;

  • LIST is not used on the request path.

  • Step 2: Write the failing response-loss matrix

Inject loss after operation reserve, data PUT, data HEAD, reference create, terminal record CAS, and retirement. Expected outcomes must be terminal replay, deterministic continuation, typed conflict, or INDETERMINATE; never blind duplicate mutation or producer replay.

  • Step 3: Verify RED

Run:

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:signatureAge bound, or at/after credential/session horizon - qualified maximum clock skew;

  • missing exact operation/content identity, checksum, media type, encryption/retention header, or create-only precondition required by the named profile;

  • a named direct-single-hard-ceiling profile when the destination cannot prove a provider-enforced hard size ceiling;

  • an unhealthy/unqualified local clock or NTP status before any new grant;

  • direct download before application authorization or for unpublished/retired/wrong-version data.

  • Step 2: Write failing lifecycle and redaction tests

Prove the exact grant linearization:

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_PROGRESS plus exact pending-effect evidence, then calls create; provider-accepted/create-response-lost recovery uses bounded, paginated, exact operation-key discovery and never blindly initiates again; ambiguity remains INDETERMINATE/orphan;

  • a confirmed start persists the provider upload ID privately before returning a session;

  • part grants are bounded by part number/count/size/concurrency/expiry and server generation;

  • acknowledgement accepts only an allowlisted, bounded provider completion claim and converts it to an opaque PartReceiptToken;

  • a reissued part grant cannot let a stale late request silently replace an acknowledged part;

  • completion closes grant/ack admission, waits for every issued generation expiry plus qualified clock skew and maximum in-flight horizon (or proves controlled-ingress drain), then paginates ListParts;

  • completion compares each current ledger revision/token against exact provider part number, private ETag, checksum algorithm/type/scope, and length before a COMPLETE_IN_PROGRESS CAS;

  • conditional complete is followed by exact final version/size and FULL_OBJECT SHA-256 verification; multipart ETag or composite checksum is never treated as the logical full digest;

  • incomplete, duplicate-conflicting, stale, or over-budget ledgers fail before provider complete;

  • provider upload ID and part ETag/checksum never cross the application contract.

  • Step 2: Write failing race/response-loss tests

Cover:

  • initiate accepted followed by lost response and ambiguous orphan discovery;

  • grant response loss/reissue;

  • part response loss and acknowledgement replay;

  • acknowledge versus complete;

  • late part request versus complete/abort;

  • complete response loss and exact HEAD/ListParts resolution;

  • 409 complete semantics;

  • abort response loss and orphan discovery;

  • process restart with active sessions;

  • expiry plus maximum in-flight horizon before cleanup eligibility.

  • retirement/abort racing the admission-close and COMPLETE_IN_PROGRESS CAS.

  • Step 3: Verify RED

Run:

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

Approved 2026-07-29: The user authorized continued implementation using the recommended defaults. The deep design now records the exact decisions: test-fake scanner only, integrity-verified-reference, additive POST/GET with stable 202, Base64 SHA-256 multipart field, required keyed idempotency with atomic TX1 reservation/intent, existing poster:write, legacy compatibility profile, new V2 event without v1 removal, intentionally scoped OpenAPI snapshot, and additive forward-only V8 dual read.

Do not execute Tasks 2024 until the approved design records all of the following:

  • staged scan fencing may be implemented with a test fake, but no production scanner/provider is claimed;
  • the sample's first migrated profile is either integrity-verified-reference or an explicitly named scan-gated profile with a real scanner owner;
  • the additive endpoint proposal POST /posters/{id}/imagePublications, status, authorization, request size/media policy, and locator-free response fields;
  • the asynchronous response contract: POST always returns the same bounded 202 reservation (opaque publication operation plus status link) replayed by IdempotencyExecutor; a separately authorized GET status resource reports progress and only exposes the published reference at READY. The POST never sometimes returns a final payload under the same idempotency record;
  • whether exact file SHA-256 is supplied as a bounded multipart field or a newly registered HTTP header, including its canonical encoding; it is required before TX1, validated again while staging, and multipart boundary/order is never part of the semantic fingerprint;
  • required Idempotency-Key, fingerprint scope, and the rule that the first committed UploadIntent allocates/reuses the stable ObjectOperationId;
  • the exact IdempotencyExecutor shape: inside one TX1 it atomically claims/completes the stable 202 reservation and creates-or-reads the durable intent keyed by versioned HMAC scope digest; the same HTTP invocation then consumes the request-bound producer outside TX through stage/verify and TX2 PENDING before returning that reservation; finalize/READY is asynchronous operation-keyed continuation, and generic replay never allocates a second operation;
  • the current sample remains permission-based (poster:write) because Poster has no owner attribute; do not invent per-resource ownership checks without a separate domain/schema design;
  • the explicit profile/release gate for the legacy /posters/{id}/image endpoint;
  • a consumer inventory and owner approval for the existing poster.image-attached broker event; choose a new versioned event type/envelope (preferred) or an explicit bounded dual-publish window, consumer migration evidence, rollback, and zero-consumer proof before v1 removal. Never rename imageKey to reference under the same unversioned event contract;
  • intentional OpenAPI snapshot approval;
  • the additive V8 schema and forward-only rollback window.
  • the compatibility model during V8: existing legacy attachments remain readable/publishable, legacy writes are restricted to the compatibility controller/profile, and the canonical profile writes only READY opaque references.

If these decisions change the deep design, amend and re-review the design first. Planning this gate does not constitute API approval.

Batch E — Phase 5: Staged publication and sample migration

Task 20: Implement staged integrity/scan/publication and application handoff fencing

Files:

  • Create under src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/: StagedObjectPublicationKernel.java, ObjectIntegrityVerificationService.java, ObjectScanVerdictPolicy.java, ObjectReferencePublicationService.java, ObjectPublicationHandoffService.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java

  • Modify: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java

  • Modify: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java

  • Step 1: Write failing staged-state tests

Prove:

  • stage returns only an ObjectStageHandle;

  • public inspect/read/download grant reject staged/quarantined objects;

  • integrity mismatch never becomes verified or published;

  • a scan-required destination cannot finalize before CLEAN;

  • MALICIOUS and INDETERMINATE fail closed;

  • verdict binds exact stage, object version, scan operation, scanner policy/version, and record revision;

  • duplicate same verdict replays and stale/different verdict conflicts;

  • finalize is the first operation to create an opaque published reference.

  • finalize first appends an immutable manifest revision binding ObjectId and exact immutable provider version/evidence, then conditionally creates/CASes the small reference-current pointer; response loss resolves both exact records and never rebuilds from current binding defaults.

  • Step 2: Write failing handoff/abort race tests

Cover:

  • claim, renew, release with monotonically increasing fence;

  • stale worker cannot mark PENDING/READY or release a newer claim;

  • active claim blocks abort;

  • claim expiry alone does not authorize destructive abort;

  • application intent first CASes ABORT_AUTHORIZED and then issues an exact ObjectAbortAuthorization;

  • late finalize versus abort authorization;

  • missing application intent never triggers auto-delete;

  • object without matching intent is quarantined/reported.

  • Step 3: Verify RED

Run:

cd src
./gradlew :adapter:outbound:objectstorage:test \
  --tests '*StagedObjectPublicationKernelTest' \
  --tests '*ObjectScanVerdictFenceTest' \
  --tests '*ObjectPublicationHandoffRaceTest' \
  --tests '*ObjectControlRecordCodecTest' \
  --tests '*ObjectStorageCapabilityConfigTest' \
  --tests '*ObjectStorageScanMaintenanceConfigTest' --console=plain

Expected: compilation failure because staged/handoff implementations do not exist.

  • Step 4: Implement the minimum staged kernel

Use the existing semantic ports from Task 4. Integrity verification may stream the unpublished exact version through the narrow maintenance/scan read path. Scanner policy remains an input verdict seam, not an objectstorage-owned malware engine. The handoff service never imports persistence/sample types and accepts only application-provided claim/authorization contracts. Register the handoff durable family in the closed codec with golden/old-reader fixtures and expose publication only through the selected normal mutation facade. Scan exact-version read remains a separate ObjectScanMaintenancePort facade/config with explicit scanner-workflow activation; no scan or privileged purge port is registered in the normal context. Extend the disabled/unselected/selected ApplicationContextRunner matrix and prove the normal facade cannot be cast or injected as either privileged type.

  • Step 5: Verify GREEN

Run the command from Step 3. Expected: PASS. This is R1 protocol evidence only; without a production scanner, object-storage-quarantine-publication remains below R2.

Task 21: Add the forward-only Poster image intent and dual-read schema

Files:

  • Modify: src/sample-portfolio/build.gradle

  • Modify: src/sample-portfolio/gradle.lockfile

  • Create: src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/: PosterImageUploadIntent.java, PosterImageUploadIntentState.java, PosterImageUploadIntentStorePort.java, PosterImageUploadIntentClaim.java, PosterImageUploadIntentConflictException.java, PosterImageRetirementIntent.java, PosterImageRetirementIntentState.java, PosterImageRetirementIntentStorePort.java, PosterImageIdempotencyScopeDigest.java, PosterImageIdempotencyScopeDigesterPort.java, PosterImageIdempotencyKeyEpochPort.java, PosterImageSanitizedIdempotencyContextFactory.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/: PosterImageAttachment.java, LegacyPosterImageAttachment.java, PublishedPosterImageAttachment.java, PosterImageReference.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/: PosterImageUploadIntentEntity.java, PosterImageRetirementIntentEntity.java, PosterImageIdempotencyKeyEpochEntity.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/: PosterImageUploadIntentJpaRepository.java, PosterImageUploadIntentRepositoryAdapter.java, PosterImageRetirementIntentJpaRepository.java, PosterImageRetirementIntentRepositoryAdapter.java, PosterImageIdempotencyKeyEpochJpaRepository.java, PosterImageIdempotencyKeyEpochRepositoryAdapter.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/: PosterImageIdempotencyKeyRingSettings.java, PosterImageIdempotencyConfig.java

  • Modify: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java

  • Modify: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java

  • Modify: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java

  • Create: src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java

  • Create: src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java

  • Modify: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java

  • Modify: src/sample-portfolio/src/main/resources/application.yml

  • Modify: .github/workflows/object-storage-qualification.yml

  • Modify: .github/ci-gate-matrix.yml

  • Step 1: Register the non-skipping migration lane and locks

Register posterImageMigrationTest with Docker/PostgreSQL prerequisites that fail when absent, add it to the container CI workflow/gate matrix, then regenerate/review the lock before using the lane as RED:

cd src
./gradlew :sample-portfolio:resolveAndLockAll --write-locks
./gradlew :sample-portfolio:verifyDependencyLocks --console=plain
cd ..
bash .github/scripts/verify-gate-matrix.sh

Expected: the task/configuration exists and locks are valid; no migration behavior is claimed yet.

  • Step 2: Write failing migration/repository tests

Run real PostgreSQL/Flyway and prove:

  • fresh V1V8 and V7→V8 upgrade;
  • V6 is unchanged;
  • existing poster.image_key rows remain readable through the internal dual-read mapper;
  • new READY attachments store only bounded opaque image_reference;
  • legacy rows map to a discriminated legacy attachment and remain readable/publishable in the approved compatibility window; canonical writes attach only READY published references;
  • pending state lives in the intent, not as an attached domain image;
  • active operation/idempotency uniqueness;
  • a bounded, non-reversible idempotency-scope digest (not the raw header/principal) maps retries to the committed operation, and the stored request fingerprint detects scope reuse with new intent;
  • state/revision/fence CAS permits one winner;
  • SUPERSEDED is a durable terminal intent state and requires a same-transaction exact losing-reference retirement row when publication already occurred;
  • intent survives Poster deletion long enough to reconcile/retire;
  • no object payload or presigned URL column exists;
  • replacement/delete work uses a separate durable poster_image_retirement_intent row and cannot be inferred from a deleted Poster or overloaded upload intent.

The V8 intent table must include stable operation identity/epoch/destination, request fingerprint, bounded idempotency-scope digest, expected poster version, exact content identity/media type, state, stage handle, handoff claim/fence/expiry, published reference/version, replaced reference, retry/error timestamps, and optimistic revision. Index the unique scope, operation, worker claims, and expiry. Avoid a cascading FK that deletes required cleanup evidence.

Store idempotency lookup as (hmac_key_version, digest), never raw scope or plain SHA-256. The HMAC input is a frozen poster-image-idempotency-scope-v1 domain separator followed by length-prefixed UTF-8 fields for tenant presence/value, principal, use-case, and Idempotency-Key; ambiguous concatenations and tenant A/B must produce different golden vectors. Load an operator-supplied, permission-checked versioned key-ring file from the typed app.poster-image.idempotency.key-ring-path setting; do not add it to root src/.env or an app-bootstrap-only env registry. The canonical production profile requires a nonempty active key. Retain old keys for at least the maximum intent/idempotency/reconciliation horizon and rotate overlap-first. A DB-coordinated key-epoch row is locked in the same reservation transaction: all pods must possess the DB-active key; a stale pod fails new admission, and activation changes only after every pod has the new retained key. Reservation queries all retained-version digests before inserting under the active version. Tests race old/new pods across activation and prove one intent, same-scope lookup, and no key material disclosure. Do not reuse the logging/privacy salt.

Before invoking the generic executor, derive a storage-safe IdempotencyContext: the optional tenant and principal dimensions become separately domain-separated HMAC aliases, the idempotencyKey dimension becomes the full versioned scope digest, and useCaseName is the fixed publication-reservation identifier. The raw client key, tenant, and principal remain request-memory inputs only. Tenant presence/value remains part of every alias/digest, so tenant isolation is not collapsed. Golden and ambiguous-tuple tests cover both the intent key and sanitized generic scope.

  • Step 3: Verify RED

Run:

cd src
./gradlew :sample-portfolio:test \
  --tests '*PosterImageReferenceTest' \
  --tests '*PosterTest' \
  --tests '*HmacPosterImageIdempotencyScopeDigesterTest' \
  --tests '*PosterImageIdempotencyConfigTest' \
  --tests '*PosterImageUploadIntentRepositoryIntegrationTest' \
  --tests '*PosterRepositoryAdapterIntegrationTest' --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain

Expected: compilation/migration failures because V8, the intent model, key-ring binding, and non-skipping qualification lane do not exist. Docker/PostgreSQL absence is an actionable failure, not a skip or passing default test.

  • Step 4: Implement additive persistence only

Do not call object storage from Flyway, an entity callback, repository mapper, or transaction listener. Keep image_key for dual read; add image_reference, upload intent, and retirement intent additively. During the approved window, the domain represents legacy versus published attachment explicitly and permits existing legacy attachments; the canonical writer accepts only a READY opaque reference. Keep the existing imageKey() accessor, legacy command, and PosterImageAttached event source-compatible through this task so all current main sources compile.

  • Step 5: Verify GREEN

Run the exact Step 3 commands again, then verify locks:

cd src
./gradlew :adapter:outbound:persistence-jpa:test \
  --tests '*IdempotencyStoreAdapterTest' --console=plain
./gradlew :sample-portfolio:test \
  --tests '*PosterImageReferenceTest' \
  --tests '*PosterTest' \
  --tests '*HmacPosterImageIdempotencyScopeDigesterTest' \
  --tests '*PosterImageIdempotencyConfigTest' \
  --tests '*PosterImageUploadIntentRepositoryIntegrationTest' \
  --tests '*PosterRepositoryAdapterIntegrationTest' --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
./gradlew :sample-portfolio:resolveAndLockAll --write-locks
./gradlew :sample-portfolio:verifyDependencyLocks --console=plain

Expected: PASS, and the posterImageMigrationTest result XML reports zero skipped tests.

  • Step 6: Verify rollback window

The non-skipping PosterImageV8MigrationQualificationTest must run a V7-compatible SQL/JPA projection against the expanded schema, including old INSERT/UPDATE/read behavior. Document that rollback is binary-only while old code ignores additive columns; there is no down migration, column drop, intent deletion, or object mutation.

Task 22: Move Poster publication through short transactions and crash-safe handoff

Files:

  • Create: src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java

  • Create: src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java

  • Modify: src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java

  • Modify: src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java

  • Modify: src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java

  • Test: src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepositoryTest.java

  • Modify: src/adapter/outbound/persistence-jpa/README.md

  • Modify: src/adapter/outbound/persistence-jpa/CLAUDE.md

  • Modify: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationResult.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintFactory.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintCodec.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageUploadReconciliationJob.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/: PosterImageUploadReconciliationSettings.java, PosterImageObjectStorageConfig.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/: PosterImageAttachmentPrepared.java, PosterImageAttachmentReadyV2.java, PosterImagePublicationEventPublisher.java

  • Modify: src/sample-portfolio/src/main/resources/application.yml

  • Create: docs/evidence/object-storage/poster-image-event-consumers.md

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicyTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventContractTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfigTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationCrashMatrixTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationConcurrencyTest.java

  • Test: src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageReservationAtomicityQualificationTest.java

  • Step 1: Write failing transaction-boundary tests

Use a tracking TransactionPort and fake object ports. Fail the test if producer invocation, stage/inspect/finalize/abort/resolve, or any external I/O occurs while a transaction is active. Prove the order:

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 V8 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored aliases/digests retain tenant separation.

Reconciliation settings are constructor-bound, disabled by default, and bound batch size, claim/renew duration, fixed delay, retry/backoff, concurrency, and shutdown grace. The disabled context creates no scheduler/thread and performs no DB/object access. The explicit canonical sample profile enables the new capability; the legacy-only profile remains unchanged until Task 23.

  • Step 6: Verify GREEN

Run the command from Step 4 plus:

cd src
./gradlew :sample-portfolio:test \
  --tests '*PosterControllerWireTest' \
  --tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain

Expected: PASS with both the new semantic application slice and unchanged legacy slice compiling; no endpoint switches in this task.

Task 23: Add bounded multipart ingress and an approved locator-free response

Files:

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/: PosterImagePublicationResponse.java, PosterImagePublicationStatusResponse.java

  • Create: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/: PosterImagePublicationController.java, LegacyPosterImageController.java

  • Create under src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/: PosterImageApiSettings.java, PosterImageApiConfig.java

  • Move: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java to src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/legacy/LegacyUploadPosterImageCommand.java

  • Move: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java to src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/legacy/LegacyUploadPosterImageUseCase.java

  • Modify: src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java

  • Modify: src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java

  • Modify: src/sample-portfolio/src/main/resources/application.yml

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageControllerWireTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationStatusAuthorizationTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfigTest.java

  • Test: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/architecture/PosterImageIngressArchitectureTest.java

  • Modify: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java

  • Modify: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java

  • Modify: src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json

  • Create: src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-publication-snapshot.json

  • Modify: src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java

  • Modify, only if Approval Gate A selects a new header: docs/registries/headers.yaml

  • Step 1: Write failing bounded-ingress tests

Prove:

  • no MultipartFile.getBytes() or full file materialization;

  • inbound adapter opens/closes the multipart stream inside the producer callback;

  • request-bound producer is fully consumed before POST returns; any attempted use after response is rejected, and a background worker never receives a MultipartFile/request stream;

  • chunks do not exceed the application sink limit;

  • declared/exact size and SHA-256 representation are validated;

  • client disconnect/read failure/cancellation is not upload success;

  • controller/producer signatures leak no MultipartFile into sample application/domain;

  • transport multipart/body hard limit and header syntax stay inbound; Poster allowed media/logical size/permission/idempotency stay in PosterImagePublicationPolicy/use case; destination max/checksum/encryption stay in the compiled object-storage binding. Architecture tests reject those business rules in controller/mapper/producer/configuration.

  • Step 2: Write the failing wire/API contract

For the exact approved endpoint, prove:

  • required authorization and Idempotency-Key;
  • accepted digest input and canonical mismatch error;
  • POST returns 202 only after exact stage evidence and TX2 PENDING are durable, with the stable opaque publication operation and status link replayed for the same key/fingerprint;
  • validation/read/stage/TX2 failure returns the normalized 4xx/5xx and does not consume the request stream after response; a same-key/same-body retry reuses the reservation/operation and supplies the producer again until staged evidence exists;
  • authorized GET status returns progress and, only at READY, opaque reference, size, media type, and logical digest; operation tokens are unguessable but not authorization, missing operation is 404, and missing poster:write is rejected before lookup;
  • the new publication/status DTOs contain no raw key, location, bucket, path, file://, s3://, presigned URL, provider version, or internal handle;
  • legacy endpoint/response exists only under the explicit compatibility profile;
  • legacy and canonical image controllers are distinct conditional beans and never active simultaneously; disabled mode exposes neither;
  • signing/direct endpoints are absent.

The pre-existing general PosterResponse.imageKey and legacy StoredObjectResponse remain unchanged during the approved REST compatibility window. Therefore this task claims only the new publication surface is locator-free; removing/versioning the old field requires consumer inventory, a separate API version/rollback approval, and new snapshot. Do not silently edit both DTO shapes.

  • Step 3: Verify RED

Run:

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 V8 retirement table and calls only logical exact-reference retirement outside DB transactions. A disabled context creates no scheduler/thread; stale fence/takeover is rejected and response loss remains operation-keyed exact-reference/version reconciliation, not blind retry. TX3 replacement and DeletePosterUseCase insert the row in the same DB transaction; its FK/retention preserves work after Poster deletion. Physical purge remains separately privileged in Tasks 2629.

All three named use cases implement the repository CommandUseCase convention with explicit commands/results and @UseCaseCapability; jobs invoke those boundaries rather than calling a repository/port directly. Adoption carries the admin-only legacy exception, while logical retirement/reconciliation use only opaque exact references.

  • Step 5: Run Batch E checkpoint

Run:

cd src
./gradlew \
  :application-core:check \
  :adapter:outbound:persistence-jpa:check \
  :adapter:outbound:objectstorage:check \
  :sample-portfolio:check --console=plain
./gradlew :sample-portfolio:posterImageMigrationTest --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot --console=plain

Expected: PASS. Record sample workflow R1 evidence only. Do not remove legacy types, columns, objects, readers, or endpoint merely because the new happy path passes.


Approval Gate B — Production AWS topology and authority

Do not execute Batch F protected/provider mutations until an authorized deployment owner provides:

  • a pre-provisioned regional AWS S3 general-purpose bucket plus isolated disposable qualification, backup-export, and restored-destination namespaces;
  • exact account/bucket owner, region, endpoint/network path, ownership controls, Block Public Access, versioning, lifecycle, incomplete-multipart policy, encryption/KMS, retention profile, bounded s3:signatureAge policy, qualified clock-skew source, and CORS decisions;
  • a workload role/default-chain credential path with least-privilege data/control/qualification/ maintenance separation and credential-refresh evidence;
  • an IaC-produced, expiry-bounded, Ed25519-signed capability attestation and trusted public key;
  • permission to run non-destructive safe probes and separately authorized cleanup in the qualification namespace;
  • permission to create a bounded backup of exact test data/control versions and restore it into the disposable restored namespace for R2 reconciliation evidence; this is not authority for a regional production DR game day;
  • KMS/S3 throttle, permission revocation, process kill, network fault, and cleanup test windows;
  • CI secret handling and evidence retention;
  • the exact cards/destination profiles proposed for R2.
  • Object-Lock-enabled topology and separate privileged retention/hold/purge role only if the retention card is proposed; otherwise that card remains below R2.

The runtime must not create or alter buckets, IAM, KMS, lifecycle, versioning, Object Lock, BPA, ownership controls, or CORS. If the authority/topology is unavailable, complete the code/tests that do not require it and leave the exact card below R2; do not substitute MinIO or mocks.

Batch F — Phase 6: Production security, maintenance, and exact R2 evidence

Task 25: Verify signed deployment attestation and fail-closed AWS startup qualification

Files:

  • Create under src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/: ObjectStorageDeploymentAttestation.java, ObjectStorageAttestationCodec.java, Ed25519ObjectStorageAttestationVerifier.java, ObjectStorageSafeProbe.java, AwsS3StartupQualifier.java, ObjectStorageQualificationCache.java, ObjectStorageQualificationFailure.java, ObjectStorageClockHealth.java, AwsS3VersioningPropagationQualifier.java

  • Create under src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/security/: ObjectStorageCredentialPolicy.java, ObjectStorageEndpointPolicy.java, ObjectStorageEncryptionPolicy.java, ObjectStorageSecurityValidator.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java

  • Modify: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageAttestationVerifierTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3StartupQualifierTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/security/ObjectStorageSecurityConfigurationTest.java

  • Step 1: Write failing attestation tests

Use deterministic Ed25519 fixtures and reject:

  • invalid signature, non-canonical document, unknown/newer schema;

  • expired/not-yet-valid evidence or expiry beyond configured maximum;

  • wrong account/owner/bucket/region/provider/deployment identity;

  • binding/policy/encryption/versioning/lifecycle/ownership/BPA/profile digest mismatch;

  • missing/mismatched versioningActivatedAt, approved propagation-soak duration, maximum s3:signatureAge, time-source identity, or qualified maximum clock skew;

  • an attestation that advertises a card combination not explicitly qualified;

  • key/document path traversal, symlink, world-writable file, oversize, or value leakage;

  • last-known-good use without a signed grace policy.

  • Step 2: Write failing startup/security tests

Prove:

  • required destination fails startup on missing/mismatched evidence;

  • minimal probe uses expected owner and only the reserved qualification namespace;

  • probe does bounded create/HEAD/GET/conditional-CAS/delete only when authorized;

  • no provisioning/config mutation API is called;

  • production requires HTTPS, approved public presign host, default-chain temporary credentials, expected owner, private ownership/BPA, and approved encryption profile;

  • new grant admission requires healthy time synchronization and an expiry strictly inside both credential horizon minus qualified skew and the attested signature-age ceiling;

  • after the attested versioning activation time plus approved soak, a bounded sentinel create/read/new-version/exact-version-delete/delete-marker check passes before mutation admission; a recent/unknown activation or failed sentinel keeps the destination unqualified;

  • static access/secret literals, anonymous credentials, public ACL, governance bypass, plaintext endpoint, and auto-create fail;

  • disabled/unselected provider performs no attestation read, credential resolution, client creation, DNS, or probe.

  • Step 3: Verify RED

Run:

cd src
./gradlew :adapter:outbound:objectstorage:test \
  --tests '*ObjectStorageAttestationVerifierTest' \
  --tests '*AwsS3StartupQualifierTest' \
  --tests '*ObjectStorageSecurityConfigurationTest' \
  --tests '*S3ProviderCompositionTest' --console=plain

Expected: compilation failure because attestation/production qualification does not exist.

  • Step 4: Implement bounded verification and qualification

Use JDK Ed25519 and strict canonical JSON. Cache exact evidence with expiry and refresh before its horizon. Refresh failure never recompiles an existing operation against current settings. After expiry, block new required mutations/grants; published exact-version reads continue only under an explicit signed read-continuity policy plus live safe probe. Emit redacted audit/metrics later in Task 28. Versioning propagation is evidence, not a sleep inside startup: compare the signed activation instant to the approved soak and run the sentinel only after the horizon. Wire qualification only through the selected S3 contribution; disabled/unselected contexts remain side-effect free and the normal context still has no privileged purge bean.

  • Step 5: Verify GREEN

Run the command from Step 3. Expected: PASS.

Task 26: Implement version-aware retirement, retention evidence, and privileged exact purge

Files:

  • Create under src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/: VersionAwareObjectRetirementService.java, PrivilegedObjectPurgeService.java, ObjectRetentionDecision.java, ObjectPurgeAuthorization.java

  • Create under src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/: S3RetentionEvidenceReader.java, S3VersionAwarePurgeProvider.java, S3PrivilegedPurgeProviderContribution.java, S3PrivilegedPurgeClientFactory.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeSettings.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfig.java

  • Create: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedProviderAssembler.java

  • Modify: src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfigTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PrivilegedPurgeCompositionTest.java

  • Modify: src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/VersionAwareObjectRetirementTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/PrivilegedObjectPurgeTest.java

  • Test: src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3RetentionEvidenceReaderTest.java

  • Step 1: Write failing lifecycle/privilege tests

Prove:

  • retirement makes a published reference unavailable through business read/grant but does not imply physical deletion;

  • exact object version, reference revision, handoff fence, retention policy revision, and purge operation are required;

  • a delete marker is not evidence that a noncurrent version was physically purged;

  • active retention/legal hold yields HELD, not success or bypass;

  • HELD requires exact successful GetObjectRetention/GetObjectLegalHold evidence; a generic 403, timeout, unavailable API, or unmapped provider error is UNKNOWN/fail-closed, never held;

  • governance bypass is absent from the normal role/path;

  • general business composition cannot obtain ObjectPurgeMaintenancePort;

  • response loss resolves exact version state before retry;

  • object/version mismatch, unknown schema, or missing authorization never deletes.

  • if object-storage-retention is proposed at R2, versioning, exact noncurrent-version purge, delete-marker behavior, lifecycle interaction, retention/legal-hold reads, and the split privileged role are mandatory; otherwise the card remains below R2.

  • Step 2: Verify RED

Run:

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:signatureAge positive/negative test; unhealthy time blocks new grant admission;

  • DNS/connect/TLS/acquire/read/write/API deadline and SDK retry amplification;

  • throttle, permission revocation, response drop, process halt/restart, rolling control schema;

  • managed/direct single/multipart, download/range, quarantine only if a real scanner exists, retention, and reconciliation card-specific requirements;

  • bounded export/restore of exact data, manifests, reference pointers, operation/control records, and version evidence into the authorized disposable restored namespace, followed by reconciliation and digest/reference integrity audit;

  • observability/redaction and no silent skip.

  • Step 3: Run protected commands

Run in the authorized lane:

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:

  1. application/transport/domain boundary and module edges;
  2. operation fingerprint, state machine, CAS, response-loss and crash-gap recovery;
  3. provider exactness, checksum/multipart/presign/version/retention semantics;
  4. configuration, credentials, TLS, attestation, redaction, resource/lifecycle safety;
  5. sample DB/API migration, rollback, dual read, retirement;
  6. readiness claims versus actual non-skipping evidence.

Resolve every blocker/high (or Critical/Important) finding and rerun affected focused plus full gates.

  • Step 6: Capture the required LLM Wiki record

Before the implementation completion response, read the canonical vault instructions and update:

/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/<branch-name>.md

Record files, decisions, test commands/results, failures/blocks, evidence grade, and derivative raw notes with bidirectional links. If the canonical vault is absent or inaccessible, do not use a different clone; record the exact block in the plan/design and final response.

  • Step 7: Report exact completion

The handoff must list changed files, implementation slices, exact commands/results, failures or unrun protected lanes, Wiki capture, card/provider/profile/evidence tuples, rollback posture, and remaining gates. Never use “Objectstorage R2” as an unqualified completion statement.


Phase 7 follow-up plan, not executable here

Create a separately approved plan only after Phase 6 evidence exists. It must cover:

  • multi-node operation/maintenance fencing and failover;
  • rolling writer/reader schema compatibility and old binding/policy restoration;
  • regional/cluster disaster-recovery game day and failover restore integrity audit, building on but not replaced by Phase 6's bounded single-destination restored-namespace R2 test;
  • sustained load, quota/capacity exhaustion, credential/KMS rotation under load;
  • provider upgrade/rollback and operational game day;
  • a split/no-split ADR based on actual dependency/release/security/runtime divergence.

Until that plan passes, no exact card may claim R3. Disabling optional cards and returning new admission to the last qualified provider/schema revision is the rollback posture; live operation, reference, session, and audit records remain readable/reconcilable.