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