chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both this worktree and the main checkout before this session began: the initial HTTP Client platform implementation (previously untracked), the redis-lab removal, and the JPA / object-storage / notification integration work. Kept separate from this session's HTTP Client review response, which lands in the following commit, so the two bodies of work stay reviewable apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
@@ -0,0 +1,176 @@
|
||||
# Fileserver configuration
|
||||
|
||||
Every key below lives under `app.fileserver-platform` (environment form
|
||||
`APP_FILESERVER_PLATFORM_*`). That namespace is the HTTP platform's alone: `app.fileserver.*`
|
||||
belongs to the R2 tabular publication capability and `app.file-export.*` to the R1 CSV export, and
|
||||
the three are deliberately separate so switching one on cannot switch on another.
|
||||
|
||||
While `app.fileserver-platform.enabled` is false none of these keys is bound at all — the
|
||||
auto-configuration that binds them is not processed — so a malformed value in a block nobody
|
||||
enabled cannot fail a startup. Once enabled, binding is strict: an unknown key under the prefix is
|
||||
refused rather than ignored. The defaults are the conservative ones: the
|
||||
capability is off, the admin plane is off, background reclamation is off, and there is no permissive
|
||||
authorization fallback. Turning the capability on is a deliberate act, and so is every surface it
|
||||
exposes.
|
||||
|
||||
## Minimum to start
|
||||
|
||||
```yaml
|
||||
ca-skeleton:
|
||||
fileserver:
|
||||
enabled: true
|
||||
instance-id: ${HOSTNAME} # writer-lease owner; must be unique per node
|
||||
storage:
|
||||
root: /var/lib/backend/files # absolute, outside any webroot or config dir
|
||||
security:
|
||||
access-policy: role-based # or supply your own FileAccessPolicy bean
|
||||
observability:
|
||||
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||
```
|
||||
|
||||
Startup fails, rather than degrading, when any of these is missing or unsafe:
|
||||
|
||||
| Condition | Why it is fatal |
|
||||
| --- | --- |
|
||||
| `security.access-policy` left at `required` with no `FileAccessPolicy` bean | a file capability that authorizes by default is worse than one that refuses to start |
|
||||
| `observability.fingerprint-key` unset while metrics are on | an unkeyed digest of an enumerable identifier is reversible |
|
||||
| the storage root fails a mandatory capability probe | a volume that cannot create atomically, keep staging and content on one FileStore, or refuse symlinks is unsafe, not degraded |
|
||||
| `storage.publish-mode: atomic-move-required` on a volume where the probe could not prove an atomic move | the configured guarantee cannot be delivered |
|
||||
| `security.access-policy: unenforced` under a `prod` profile | a value that was convenient in development must not survive promotion |
|
||||
|
||||
## Authorization — `security`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `access-policy` | `required` | `required` (supply your own bean), `role-based`, or `unenforced` |
|
||||
| `read-roles` | `ROLE_FILE_READ` | grants metadata read and download |
|
||||
| `write-roles` | `ROLE_FILE_WRITE` | grants create, append, finalize, delete, copy, move |
|
||||
| `admin-roles` | `ROLE_FILE_ADMIN` | grants reverify and force-delete, and gates `/internal/fileserver/**` at the servlet chain |
|
||||
|
||||
There is no anonymous-read switch. Every Fileserver route is authenticated by the servlet chain
|
||||
before any application policy is consulted, so such a setting could only ever have described a
|
||||
permission the transport had already refused — a configuration that reads as if it grants access
|
||||
and does not.
|
||||
|
||||
The three tiers do not inherit. An admin role cannot delete through the data plane, and a write role
|
||||
cannot reach the management plane — a role model where "can delete" implied "can force-delete" would
|
||||
make the audited plane reachable through the unaudited one.
|
||||
|
||||
`unenforced` authorizes everything and exists so a developer can exercise upload and download before
|
||||
deciding on a role model. It is refused under a production profile.
|
||||
|
||||
## Storage — `storage`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `root` | `/var/lib/backend/files` | absolute path; the only place a path exists |
|
||||
| `publish-mode` | `atomic-move-preferred` | `atomic-move-required`, `atomic-move-preferred`, `metadata-pointer` |
|
||||
| `buffer-size` | `128KB` | bounds every transfer allocation; memory never scales with file size |
|
||||
| `forbidden-root-ancestors` | `/app,/etc,/usr/share/nginx/html` | roots the storage root must not live under (webroot, config dirs) |
|
||||
|
||||
`root` must be absolute. A relative root resolves against the process working directory, which is
|
||||
one path in a container and another in a test, so it is refused at binding time.
|
||||
|
||||
Three former keys are gone, pinned as constants instead: staging and content share one FileStore,
|
||||
symbolic links are never followed, and the object and its directory are synced before READY. Each
|
||||
is an invariant the atomic publish and the namespace boundary are built on — a deployment that
|
||||
could switch one off would be running a different capability under the same name and the same
|
||||
tests.
|
||||
|
||||
The storage provider has no selector either. There is exactly one implementation, and a `type` key
|
||||
with one legal value is a promise of pluggability that nothing keeps.
|
||||
|
||||
## Upload, download, transfer
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `upload.max-file-size` | `100MB` | hard ceiling; also drives `spring.servlet.multipart.max-file-size` |
|
||||
| `upload.max-request-size` | `110MB` | request envelope; must be at least `max-file-size` |
|
||||
| `upload.initial-reservation` | `8MB` | quota reserved when the length is unknown |
|
||||
| `upload.ttl` | `1h` | how long a resumable upload stays claimable |
|
||||
| `upload.reservation-ttl` | `24h` | how long an unsettled quota reservation survives |
|
||||
| `upload.lease-duration` | `30s` | writer lease; renewed at one third of this |
|
||||
| `upload.max-parts` | `16` | multipart part ceiling |
|
||||
| `upload.require-content-length` | `false` | refuse chunked raw uploads |
|
||||
| `download.cache-control` | `private, no-store` | emitted on every content response |
|
||||
| `download.inline-allowed` | `false` | scriptable content is always an attachment regardless |
|
||||
| `download.max-ranges` | `1` | multi-range responses are opt-in |
|
||||
| `download.max-range-bytes` | `100MB` | total bytes one ranged response may cover |
|
||||
| `download.zero-copy-enabled` | `true` | hand large plaintext responses to the kernel |
|
||||
| `download.zero-copy-minimum-bytes` | `16MB` | below this the syscall setup costs more than it saves |
|
||||
| `transfer.core-size` / `max-size` / `queue-capacity` | `8` / `32` / `64` | blocking transfer pool bounds |
|
||||
| `transfer.await-seconds` | `300` | how long a transfer may occupy a pool thread |
|
||||
|
||||
Zero copy changes no header and no status. When storage declines it — an unreadable region, an
|
||||
unsupported backend — the response is streamed instead and is byte-identical.
|
||||
|
||||
## Verification — `verification`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `timeout` | `5s` | per-verifier ceiling |
|
||||
| `require-media-type-verdict` | `false` | refuse a file whose type could not be determined |
|
||||
| `inline-safe-profile` | `false` | accept scriptable content instead of quarantining it |
|
||||
|
||||
Set `inline-safe-profile: true` only when downloads are never served inline from a trusted origin.
|
||||
|
||||
## Quota and admission — `quota`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `instance-upload-permits` | `16` | concurrent uploads this node admits |
|
||||
| `scope-upload-permits` | `4` | concurrent uploads one namespace admits |
|
||||
| `direct-download-permits` | `64` | concurrent non-delegated downloads |
|
||||
| `soft-high-water` | `0.70` | storage fraction at which pressure is reported |
|
||||
| `hard-high-water` | `0.85` | storage fraction at which uploads are refused |
|
||||
|
||||
When the storage fraction cannot be read, admission treats it as unknown and does not apply the
|
||||
high-water rule — a synthetic `0` would silently disable the guard, and a synthetic `1` would take
|
||||
the capability down over a failed syscall.
|
||||
|
||||
## Background reclamation — `cleanup`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | run the cleanup worker on this node |
|
||||
| `interval` | `60s` | fixed delay between batches, not fixed rate |
|
||||
| `max-items` | `100` | items one batch may claim |
|
||||
| `max-bytes` | `1GB` | bytes one batch may reclaim |
|
||||
| `retry-backoff` | `5m` | how long a failed item waits before it is due again |
|
||||
|
||||
The worker deletes physical objects, so it is off until a deployment decides otherwise. A node
|
||||
without it still queues cleanup items; another node or an operator reclaims them. An item that fails
|
||||
eight times is abandoned rather than retried forever — it stays visible to an operator, parked
|
||||
rather than discarded.
|
||||
|
||||
## Management plane — `admin`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | expose `/internal/fileserver/**` |
|
||||
| `orphan-minimum-age` | `1h` | how long an unreferenced object must exist before a scan may name it |
|
||||
|
||||
Publishing content and committing its record are two steps. Anything younger than
|
||||
`orphan-minimum-age` is assumed to be mid-commit rather than abandoned; shortening this makes
|
||||
concurrent uploads look like orphans.
|
||||
|
||||
## Front-proxy delegation — `nginx`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | emit `X-Accel-Redirect` instead of a body |
|
||||
| `internal-prefix` | `/__files/` | must be an `internal` location resolving to the content root |
|
||||
| `object-suffix` | `.bin` | layout suffix the proxy appends |
|
||||
| `minimum-size` | `16MB` | below this the application serves the transfer itself |
|
||||
|
||||
Delegation is decided only after authorization and the READY gate, so an internal redirect can only
|
||||
ever name content the caller was already allowed to read.
|
||||
|
||||
## Protocols — `tus`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | expose the tus 1.0 endpoints |
|
||||
|
||||
The HTTPbis resumable-upload draft-12 surface is experimental and documented in
|
||||
[support-matrix.md](support-matrix.md).
|
||||
@@ -0,0 +1,213 @@
|
||||
# Fileserver — deviations from the design
|
||||
|
||||
The design specification and the implementation plan are frozen documents. Where implementation
|
||||
found them under-specified or self-contradicting, the resolution is recorded here rather than by
|
||||
editing the specification, and every entry names the test that pins the decision.
|
||||
|
||||
## Resolved inconsistencies in the state machine and contracts
|
||||
|
||||
### 1. `CREATED → FAILED` has no edge in the transition table
|
||||
|
||||
A create that fails after the record exists must end in `FAILED`, but the table has no direct edge.
|
||||
The record therefore walks `CREATED → UPLOADING → FAILED`, which is also the honest reading: the
|
||||
upload had been admitted before it failed.
|
||||
|
||||
Pinned by `UploadApplicationServiceTest` (application-core).
|
||||
|
||||
### 2. `ContentKey`'s alphabet admits a leading separator
|
||||
|
||||
The design's key pattern `[a-z0-9/_-]{16,200}` matches `/etc/passwd/...`. Rejecting an absolute path
|
||||
at the value type would change a design-fixed contract, so the stricter shape check lives in
|
||||
`PhysicalPathResolver`, per §12.2 rule 1 — the only place that turns an identifier into a path.
|
||||
|
||||
Pinned by `PhysicalPathResolverTest`.
|
||||
|
||||
### 3. `VERIFYING → DELETING` has no edge
|
||||
|
||||
Deleting a file that is mid-verification has no legal transition. The lifecycle service refuses it
|
||||
with `409 FILE_NOT_READY` rather than inventing an edge, which matches the allowed-state list the
|
||||
JPA `markDeleting` statement already enforced.
|
||||
|
||||
Pinned by `FileLifecycleServiceTest`.
|
||||
|
||||
### 4. `If-Match` is specified as an ETag but the lifecycle was designed around the row version
|
||||
|
||||
The HTTP contract sends an entity tag; the metadata store guards on a numeric version. The service
|
||||
takes `Optional<String> expectedEtag` and compares against the record's strong validator, so the
|
||||
precondition a client sends is the precondition that is checked.
|
||||
|
||||
Pinned by `FileLifecycleServiceTest`.
|
||||
|
||||
### 5. The filename policy left `:` intact
|
||||
|
||||
`C:\Windows\system.ini` sanitized to `C:Windowssystem.ini` — a drive-qualified name surviving into
|
||||
display text and headers. `:` joined the structural strip set.
|
||||
|
||||
Pinned by `FileserverHardeningContractTest` and `AmbiguousFilesystemOperationDetectorTest`.
|
||||
|
||||
## Additions the design implies but does not specify
|
||||
|
||||
### 6. `fs_recovery_item`
|
||||
|
||||
§10.2 lists five core tables and none of them can hold the recovery queue, yet §29.3 requires one:
|
||||
reconciliation reports files whose bytes and metadata disagree, and holding that list in memory
|
||||
would lose exactly the cases a restart interrupted. Added in
|
||||
`V2__fileserver_recovery_and_staging_cleanup.sql` with one open item per file, so repeated sweeps
|
||||
update a worklist rather than accumulating a log.
|
||||
|
||||
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||
|
||||
### 7. `fs_cleanup_item.upload_id`
|
||||
|
||||
A staging object is addressed by upload, not by file. Without this column a queued staging cleanup
|
||||
could name only already-published content, so a cancelled or expired upload left bytes nothing could
|
||||
find. Added in the same migration, with a check constraint that an item names exactly one target.
|
||||
|
||||
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||
|
||||
### 8. `ContentReferenceLedger` and `StagingUploadLocator`
|
||||
|
||||
The orphan scan must ask whether a record still claims a physical object, and reconciliation must
|
||||
map a file back to the upload that last staged it. Neither question is answerable through the
|
||||
design's `FileMetadataStore` or `UploadSessionStore` as written. Rather than widen those
|
||||
design-fixed interfaces, both are narrow single-method ports.
|
||||
|
||||
Pinned by `PostgreSqlFileserverReclamationIntegrationTest` and `LocalOrphanScanAdapterTest`.
|
||||
|
||||
## Interpretations
|
||||
|
||||
### 9. Quota settlement is FIFO within a scope
|
||||
|
||||
Nothing links a reservation row to the upload that took it, and the design deliberately reclaims
|
||||
stragglers by TTL and the `STALE_QUOTA_RESERVATION` cleanup type rather than threading a reservation
|
||||
id through the upload session. `QuotaCommitGateway` therefore settles the oldest live reservation in
|
||||
the file's namespace.
|
||||
|
||||
Which row closes does not change any quota decision: enforcement sums reserved and committed bytes
|
||||
per scope and never reads an individual row. Concurrent uploads of different sizes can leave the
|
||||
reserved total transiently high or low, and it converges as each settles. Durable usage with no live
|
||||
reservation behind it — an upload that outlived its TTL — is still recorded, because a ledger that
|
||||
silently under-counts is worse than one that is briefly imprecise.
|
||||
|
||||
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||
|
||||
### 10. Zero copy is a channel transfer, not a file handoff
|
||||
|
||||
Task 22 asks for zero copy on local files; §19 forbids a `Path` leaving the storage adapter, and §5
|
||||
of the plan forbids adding `Path` to the content store. WebFlux's zero-copy API takes a `Path`, so
|
||||
that route is closed.
|
||||
|
||||
The servlet path takes the other one: `ZeroCopyDownloadGateway` receives a `WritableByteChannel` from
|
||||
the transport and the storage adapter performs `FileChannel.transferTo` into it. That is a genuine
|
||||
kernel-level transfer with no filesystem concept leaving storage. The reactive path continues to
|
||||
stream with bounded demand.
|
||||
|
||||
Zero copy is an optimization with no observable difference: when storage declines, the response is
|
||||
streamed and is byte-identical.
|
||||
|
||||
Pinned by `LocalStorageGatewayContractTest` and `ZeroCopyEligibilityTest`.
|
||||
|
||||
### 11. The Fileserver JPA stores are gated on the capability switch
|
||||
|
||||
The metadata store, session store, quota service, queues, ledger, and staging locator carry
|
||||
`@ConditionalOnProperty(app.fileserver-platform.enabled)` even though the rest of
|
||||
`adapter:outbound:persistence-jpa` is unconditional.
|
||||
|
||||
Without the gate, every composition root that includes the persistence module built these beans —
|
||||
including `sample-portfolio`, which has no Fileserver — and each of them needs collaborators only
|
||||
the Fileserver configuration provides. That is the same rule the design states for the transport
|
||||
surface, applied to persistence: no surface appears merely because the dependency is present.
|
||||
|
||||
`FileStateMachine` is bound alongside them, in `FileserverStorageConfiguration`. It had no
|
||||
production binding at all before, which made the metadata store unconstructible in any
|
||||
component-scanned context.
|
||||
|
||||
Pinned by `SampleApplicationContextTest` (the capability off) and
|
||||
`FileserverRuntimeAssemblyTest` (the capability on).
|
||||
|
||||
### 12. Transaction boundaries are owned by the application services, and are deliberately narrow
|
||||
|
||||
The design does not say where a transaction begins. The repository does:
|
||||
`adapter:outbound:persistence-jpa` forbids a repository adapter from owning a `@Transactional`
|
||||
boundary, and `application-core` owns them through `TransactionPort`. The Fileserver follows that
|
||||
rule — every `Jpa*` store here declares no `@Transactional` of its own.
|
||||
|
||||
What is specific to this capability is how narrow the boundaries are. A boundary covers a contiguous
|
||||
run of metadata writes and **stops before every storage call**, because a filesystem operation
|
||||
inside a database transaction would hold a connection for the length of a byte transfer. The upload
|
||||
path therefore has three boundaries, not one: acquire the lease, transfer the bytes, commit the
|
||||
offset.
|
||||
|
||||
Where several stores must agree, they share one boundary:
|
||||
|
||||
| Unit | Why it is one boundary |
|
||||
| --- | --- |
|
||||
| reserve quota + insert record + create session | a reservation that outlived a failed insert holds capacity for a file that never existed |
|
||||
| READY transition + quota commit | a finished file whose reservation was never converted holds capacity until the reservation expires |
|
||||
| `markDeleting` + enqueue cleanup | a file that stopped being reachable with nothing queued to reclaim it is never collected |
|
||||
| content delete settlement: reclaim + retire record + close queue item | half of it leaves the item to be retried against content that no longer exists |
|
||||
|
||||
What this cannot make atomic is the storage/metadata seam itself — no database boundary could. That
|
||||
seam is exactly what the ambiguous-completion path and the reconciler exist for, and the one
|
||||
hand-written compensation that remains (staging creation failing after the records committed) is
|
||||
there for the same reason.
|
||||
|
||||
Pinned by `FileserverRoundTripContractTest` against real PostgreSQL; the application tests use
|
||||
`DirectTransactions`, which runs a boundary inline and counts it.
|
||||
|
||||
## Not implemented
|
||||
|
||||
### `AsyncContentStore`, `CapacityAwareContentStore`, `CopyCapableContentStore`, `DelegatedDownloadStore`
|
||||
|
||||
Four optional content-store SPIs are declared in `application-core` with no implementation. Each is
|
||||
an extension point for a backend this template does not ship:
|
||||
|
||||
- `AsyncContentStore` — for a backend whose native client is non-blocking. The local platform is
|
||||
blocking, and the reactive transport bridges to it on a dedicated I/O scheduler.
|
||||
- `CapacityAwareContentStore` — capacity is reported through `StorageHealthPort` and
|
||||
`StorageUsageProbe`, which the local platform implements.
|
||||
- `CopyCapableContentStore` — server-side copy is delivered by `CopyContentGateway`; the local
|
||||
platform has no cheaper primitive than a streamed copy.
|
||||
- `DelegatedDownloadStore` — delegation is delivered at the transport boundary by the nginx
|
||||
`X-Accel-Redirect` strategy, which needs no store participation.
|
||||
|
||||
`ContentStoreCapabilities` reports what the running store actually supports, so no unimplemented SPI
|
||||
is advertised as available.
|
||||
|
||||
## Known deviation from the repository's application-layer contract
|
||||
|
||||
### 5. Fileserver application services are not `CommandUseCase` / `QueryUseCase`
|
||||
|
||||
`src/application-core/CLAUDE.md` requires every inbound port implementation to extend
|
||||
`CommandUseCase` or `QueryUseCase` and to carry `@UseCaseCapability`, which declares its transaction
|
||||
mode, idempotency and repository access. The Fileserver instead exposes multi-method services —
|
||||
`UploadApplicationService`, `DownloadApplicationService`, `FileLifecycleService`,
|
||||
`FileserverAdminService` and their `Default*` implementations.
|
||||
|
||||
This is a real deviation, not an oversight, and it is unenforced: the ArchUnit rules
|
||||
`inbound_port_implementations_end_with_use_case` and
|
||||
`inbound_port_implementations_declare_capability` only match types that implement `UseCase`, so a
|
||||
service that never does is silently exempt. The capability contract that every other feature in
|
||||
this repository declares is therefore absent here.
|
||||
|
||||
Two things follow from it. The transaction mode of each operation is expressed only by which
|
||||
`TransactionPort` method the body happens to call, rather than declared and checked. And the
|
||||
application layer holds transport policy it would not hold if each operation were a use case with
|
||||
its own command: HTTP status codes on `FileserverErrorCode`, `Range` and conditional-request
|
||||
parsing in `api.transfer`, and `Content-Disposition` construction.
|
||||
|
||||
The status mapping in particular is a deliberate trade rather than an accident. It lives in
|
||||
`application-core` so the servlet transport, the reactive transport and the Nginx delegation path
|
||||
cannot answer the same failure with three different statuses. Moving it to the transport layer
|
||||
resolves the layering complaint and reintroduces exactly that drift, which is why this is an
|
||||
architecture decision rather than a cleanup.
|
||||
|
||||
**Status: open, deliberately unresolved in this change set.** Closing it means roughly thirty
|
||||
command/query use cases, a decision about where the shared status vocabulary lives, and a change to
|
||||
the ArchUnit rules so a service that bypasses the contract fails the build instead of being exempt
|
||||
from it. That belongs in its own ADR with its own review, and doing it inside a correctness patch
|
||||
would mix a large mechanical refactor into changes that need to be readable.
|
||||
|
||||
Nothing here is pinned by a test, because the deviation is the absence of a constraint. The next
|
||||
step is the ADR, not another test.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Fileserver HTTP contract
|
||||
|
||||
Every public endpoint is listed here. `FileserverDocumentationCoverageTest` scans the controllers
|
||||
and fails if one is missing, so this file cannot silently fall behind the code.
|
||||
|
||||
## Public endpoints
|
||||
|
||||
| Method | Path | Success | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/v1/files` | `201` READY, `202` VERIFYING | multipart single upload |
|
||||
| POST | `/v1/files:raw` | `201`, `202` | the whole request body is the file |
|
||||
| POST | `/v1/files:batch` | `200` | ordered per-part results; explicitly non-atomic |
|
||||
| GET | `/v1/files/{fileId}` | `200` | public metadata; never a content key or path |
|
||||
| GET | `/v1/files/{fileId}/content` | `200`, `206`, `304` | download |
|
||||
| HEAD | `/v1/files/{fileId}/content` | `200`, `304` | identical headers, no body |
|
||||
| DELETE | `/v1/files/{fileId}` | `202`, `204` | logical delete first |
|
||||
| POST | `/v1/files/{fileId}:copy` | `202` | create-only target |
|
||||
| POST | `/v1/files/{fileId}:move` | `200` | logical namespace change only |
|
||||
| OPTIONS | `/v1/uploads` | `204` | tus capability discovery |
|
||||
| POST | `/v1/uploads` | `201` | tus creation |
|
||||
| HEAD | `/v1/uploads/{uploadId}` | `204` | tus offset |
|
||||
| PATCH | `/v1/uploads/{uploadId}` | `204` | tus append |
|
||||
| DELETE | `/v1/uploads/{uploadId}` | `204` | tus termination |
|
||||
| POST | `/v1/experimental/draft12/uploads` | `201` | Experimental; off by default |
|
||||
| PATCH | `/v1/experimental/draft12/uploads/{uploadId}` | `204` | Experimental; off by default |
|
||||
|
||||
## Management endpoints
|
||||
|
||||
Reachable only where both `app.fileserver-platform.enabled=true` and
|
||||
`app.fileserver-platform.admin.enabled=true`, gated at the servlet chain on
|
||||
`app.fileserver-platform.security.admin-roles`, and intended for a management
|
||||
port rather than the public one.
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| GET | `/internal/fileserver/storage-health` |
|
||||
| GET | `/internal/fileserver/capabilities` |
|
||||
| GET | `/internal/fileserver/orphans` |
|
||||
| POST | `/internal/fileserver/orphans:reconcile` |
|
||||
| POST | `/internal/fileserver/files/{fileId}:reverify` |
|
||||
| POST | `/internal/fileserver/files/{fileId}:force-delete` |
|
||||
| GET | `/internal/fileserver/uploads/incomplete` |
|
||||
| POST | `/internal/fileserver/uploads:cleanup` |
|
||||
|
||||
## Status codes
|
||||
|
||||
| Status | Condition |
|
||||
|---:|---|
|
||||
| `200` | metadata, full GET, batch result, move |
|
||||
| `201` | file or upload created |
|
||||
| `202` | verification or physical cleanup deferred |
|
||||
| `204` | append, cancel, bodyless update |
|
||||
| `206` | satisfiable Range |
|
||||
| `304` | validator matched on GET or HEAD |
|
||||
| `400` | malformed header or header combination |
|
||||
| `401` | unauthenticated |
|
||||
| `403` / `404` | denied, or hidden under the existence-hiding profile |
|
||||
| `409` | state, offset, or lease conflict |
|
||||
| `410` | expired upload resource |
|
||||
| `411` | `require-content-length` profile with no length |
|
||||
| `412` | precondition failed |
|
||||
| `413` | size or quota policy violation |
|
||||
| `415` | upload media type not accepted |
|
||||
| `416` | unsatisfiable Range; carries the real length |
|
||||
| `422` | digest, signature, or scanner rejection |
|
||||
| `429` | transfer admission or rate limit |
|
||||
| `503` | storage or scanner unavailable |
|
||||
| `504` | downstream timeout |
|
||||
| `507` | out of storage capacity |
|
||||
|
||||
## Failure body
|
||||
|
||||
Every failure answers `application/problem+json` with a stable code and its URN:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "urn:fileserver:problem:upload-offset-mismatch",
|
||||
"title": "Upload offset mismatch",
|
||||
"status": 409,
|
||||
"code": "UPLOAD_OFFSET_MISMATCH",
|
||||
"retryable": true,
|
||||
"ambiguous": false,
|
||||
"reconciliationRequired": false,
|
||||
"traceId": "..."
|
||||
}
|
||||
```
|
||||
|
||||
The server-side exception message never appears. `ambiguous` is the field a client must read before
|
||||
retrying: an ambiguous failure may already have taken effect.
|
||||
|
||||
## Header contract
|
||||
|
||||
| Header | Contract |
|
||||
|---|---|
|
||||
| `Content-Type` | client value is a claim; the verified type is stored separately |
|
||||
| `Content-Disposition` | `attachment` by default; scriptable types are never inline |
|
||||
| `Accept-Ranges` | `bytes` |
|
||||
| `Range` | single range by default; multi-range only under an explicit budget |
|
||||
| `Content-Range` | actual range on `206`; the unsatisfied form on `416` |
|
||||
| `ETag` | strong validator derived from the SHA-256 |
|
||||
| `Last-Modified` | metadata publication instant, never a filesystem timestamp |
|
||||
| `Cache-Control` | `private, no-store` by default |
|
||||
| `X-Content-Type-Options` | always `nosniff` on a download |
|
||||
| `Retry-After` | on retryable `409`, `429`, `503`, and `504` |
|
||||
| `X-Accel-Redirect` | internal only; never forwarded to a client |
|
||||
@@ -0,0 +1,128 @@
|
||||
# Fileserver runbooks
|
||||
|
||||
Each runbook names the exact metric that fires it and the exact command that resolves it. A runbook
|
||||
whose trigger is "someone noticed" is not actionable, so every one below starts from a signal.
|
||||
|
||||
## Storage full
|
||||
|
||||
**Signal** — `fileserver.quota{result="rejected"}` rising, or `507` responses appearing.
|
||||
|
||||
Storage capacity is exhausted or the high-water guard tripped. Uploads are rejected before any bytes
|
||||
are written, so nothing is corrupt; the system is refusing work it cannot complete.
|
||||
|
||||
```bash
|
||||
curl -s $ADMIN/internal/fileserver/storage-health | jq '.usedFraction, .usableBytes'
|
||||
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||
curl -s "$ADMIN/internal/fileserver/orphans?limit=200" | jq '[.[].sizeBytes] | add'
|
||||
```
|
||||
|
||||
Drain the cleanup backlog first — it reclaims space the system already knows is dead. Only then
|
||||
consider an orphan reconcile, and start with a dry run.
|
||||
|
||||
## Orphan growth
|
||||
|
||||
**Signal** — `fileserver.cleanup{result="skipped"}` climbing, or the orphan scan returning more
|
||||
objects each run.
|
||||
|
||||
Physical objects exist with no metadata record pointing at them. This is not immediately dangerous —
|
||||
nothing serves them — but it consumes capacity indefinitely.
|
||||
|
||||
```bash
|
||||
# Always look first. A reconcile without dryRun=false is a plan, not an action.
|
||||
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||
-H 'content-type: application/json' -d '{"limit":100}' | jq '.candidates'
|
||||
|
||||
# Apply only the fingerprints you were just shown.
|
||||
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"dryRun":false,"limit":100,"maxBytes":1073741824,
|
||||
"expectedFingerprints":["<from the dry run>"],"reasonCode":"ORPHAN_GROWTH_RUNBOOK"}'
|
||||
```
|
||||
|
||||
Echoing the fingerprints is the safety property: an object that changed between the scan and the
|
||||
apply is skipped rather than deleted.
|
||||
|
||||
## Verification backlog
|
||||
|
||||
**Signal** — `fileserver.verification.queue{age_bucket="old"}` non-zero, or files sitting in
|
||||
VERIFYING.
|
||||
|
||||
A verifier is slow or unavailable. Files stay non-public, which is the correct failure direction: a
|
||||
`RETRY` verdict never becomes an `ACCEPT`.
|
||||
|
||||
```bash
|
||||
curl -s $ADMIN/internal/fileserver/capabilities | jq '.storageType'
|
||||
# Once the verifier is healthy, quarantined files can be re-examined individually.
|
||||
curl -s -X POST "$ADMIN/internal/fileserver/files/$FILE_ID:reverify"
|
||||
```
|
||||
|
||||
Do not clear the backlog by disabling verification. A file that reached READY without an accepting
|
||||
verdict cannot be distinguished later from one that was verified.
|
||||
|
||||
## NFS ambiguity
|
||||
|
||||
**Signal** — problem documents carrying `"ambiguous": true`, or
|
||||
`fileserver.transfer.interruption{reason="stale_handle"}`.
|
||||
|
||||
An operation's outcome could not be determined: the response was lost after the write or rename may
|
||||
have landed. These are never retried automatically.
|
||||
|
||||
```bash
|
||||
# The recovery queue holds the files awaiting a decision.
|
||||
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" | jq
|
||||
```
|
||||
|
||||
Reconciliation compares the physical size and digest against the record and only confirms READY when
|
||||
all four of key, size, digest, and version agree. Anything short of that is reported, never guessed.
|
||||
|
||||
## PVC remount
|
||||
|
||||
**Signal** — startup failure naming "atomic move", "same file store", or "not writable".
|
||||
|
||||
The volume was remounted somewhere the probe can no longer prove a required capability. The
|
||||
application refuses traffic rather than serving from storage it cannot publish to atomically.
|
||||
|
||||
```bash
|
||||
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
kubectl logs job/fileserver-pvc-certification
|
||||
```
|
||||
|
||||
Compare the printed tuple with the certified one in `docs/fileserver/storage-certification.md`. A
|
||||
mismatch in CSI driver, StorageClass, access mode, or mount options is the cause; the certification
|
||||
does not carry across it.
|
||||
|
||||
## Nginx delegation failure
|
||||
|
||||
**Signal** — `fileserver.download.delegation{delegated="true"}` with client-visible `404`s.
|
||||
|
||||
The internal location is misconfigured, so the proxy cannot resolve the redirect it was handed.
|
||||
|
||||
```bash
|
||||
# The internal prefix must resolve to the content root and must be marked `internal`.
|
||||
grep -A5 '__files' infra/fileserver/nginx/nginx.conf
|
||||
curl -s $ADMIN/internal/fileserver/capabilities | jq '.capabilities.delegatedDownload'
|
||||
```
|
||||
|
||||
Turning delegation off is a safe immediate mitigation: the application serves the transfer itself,
|
||||
slower but correct.
|
||||
|
||||
```bash
|
||||
app.fileserver-platform.nginx.enabled=false
|
||||
```
|
||||
|
||||
## Cleanup backlog
|
||||
|
||||
**Signal** — `fileserver.cleanup{result="deferred"}` rising, or reclaimed bytes flat while deletes
|
||||
continue.
|
||||
|
||||
Items are being deferred faster than they drain. The usual cause is an active writer lease still
|
||||
holding staging objects, which is correct behaviour, not a fault.
|
||||
|
||||
```bash
|
||||
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" \
|
||||
| jq '[.[] | select(.leaseUntil != null)] | length'
|
||||
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||
```
|
||||
|
||||
If the deferrals are all `ACTIVE_WRITER_LEASE`, the backlog resolves itself as those uploads expire.
|
||||
Never delete staging content to clear a backlog: an upload that is mid-flight will corrupt.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Fileserver security model
|
||||
|
||||
## The rule everything else follows
|
||||
|
||||
Uploaded content is attacker-controlled. Every guard below exists because some part of the request
|
||||
— the filename, the declared media type, the range, the offset — is a value the caller chose.
|
||||
|
||||
## Path safety
|
||||
|
||||
A client value never becomes a path. The physical key is server-generated, and `ContentKey`'s
|
||||
character class excludes `.` entirely, so no traversal or extension-shaped segment survives
|
||||
validation. `DefaultPhysicalPathResolver` is the only place an identifier becomes a `Path`, and it
|
||||
normalizes and re-checks containment after construction rather than trusting the input.
|
||||
|
||||
Symlink refusal happens at open time, not only at construction. A parent directory can be replaced
|
||||
between the two, so a check that ran only at path-building time would be a race, not a guard.
|
||||
|
||||
## Filename handling
|
||||
|
||||
`OriginalFilenamePolicy` strips path separators, NUL, quoting characters, and the colon — the last
|
||||
because on Windows it opens both a drive reference and an NTFS alternate data stream, so a name that
|
||||
keeps it is still path-shaped after the slashes are gone. Control characters and bidirectional
|
||||
overrides are removed, dot runs collapsed, reserved device names guarded, and the result is bounded
|
||||
in UTF-8 bytes.
|
||||
|
||||
The sanitized name is display data. It is never used to build a key, and it reaches a header only
|
||||
through `ContentDispositionFactory`, which restricts the ASCII form and percent-encodes the UTF-8
|
||||
form.
|
||||
|
||||
## Content type
|
||||
|
||||
The client's `Content-Type` is stored as a claim. The verified type comes from the verification
|
||||
pipeline, and only the verified type is served. A claimed type that contradicts the content is
|
||||
quarantined rather than corrected.
|
||||
|
||||
Scriptable types are never served inline, whatever the caller asked for: serving stored HTML or SVG
|
||||
inline from an upload origin is a stored cross-site scripting primitive. Every download also carries
|
||||
`X-Content-Type-Options: nosniff`.
|
||||
|
||||
## Verification precedence
|
||||
|
||||
`REJECT > QUARANTINE > RETRY > ACCEPT`. A verifier that times out or throws is `RETRY`, never a
|
||||
silent pass, and an empty verifier chain answers `RETRY` rather than accepting. A file becomes
|
||||
publicly readable only after an `ACCEPT`.
|
||||
|
||||
## Range safety
|
||||
|
||||
The range budget is enforced before content is opened, so a request naming many ranges is rejected
|
||||
without amplifying into storage work. An unsatisfiable range answers `416` with the real length and
|
||||
opens nothing.
|
||||
|
||||
## Authorization
|
||||
|
||||
Every public operation calls the injected `FileAccessPolicy` before any quota reservation or storage
|
||||
mutation, so a denial leaves no record, no reservation, and no staging object. Startup refuses to
|
||||
run a production profile with an allow-all policy.
|
||||
|
||||
## Delegation
|
||||
|
||||
`X-Accel-Redirect` is emitted only after authorization and the READY gate, and only for a full,
|
||||
unconditional response. The internal prefix must be an `internal` Nginx location; the front proxy
|
||||
also strips any client-supplied delegation header so a caller cannot name an internal object.
|
||||
|
||||
## Telemetry
|
||||
|
||||
No metric label, span attribute, or audit record carries a file id, upload id, filename, path, or
|
||||
user id. Where correlation is needed the value is a keyed HMAC fingerprint — keyed because the
|
||||
identifier space is enumerable and an unkeyed digest of it is reversible by brute force.
|
||||
|
||||
## Ambiguous failures
|
||||
|
||||
A failure whose operation may already have taken effect is reported as ambiguous and is never
|
||||
retryable. On a network filesystem a lost response is indistinguishable from a rejection at the
|
||||
socket level, so anything not provably safe is treated as ambiguous and sent to reconciliation.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Storage certification
|
||||
|
||||
## Why a certification is per-volume
|
||||
|
||||
Atomic rename, same-file-store guarantees, and symlink refusal are properties of a specific
|
||||
filesystem behind a specific mount — not of "Kubernetes" or "a PVC". Change the CSI driver, the
|
||||
StorageClass, the access mode, the backend, or the mount options and any of them can differ. A
|
||||
certification that does not name all five is not transferable.
|
||||
|
||||
## What is certified
|
||||
|
||||
| Property | Why it matters |
|
||||
|---|---|
|
||||
| Same file store for staging and content | A rename across stores is a copy, so publication stops being atomic. |
|
||||
| Atomic rename | The publish path's default strategy. |
|
||||
| Atomic create (`O_EXCL`) | Makes a publish create-only rather than a silent overwrite. |
|
||||
| Symlink refusal | Stops a replaced parent from redirecting a write outside the root. |
|
||||
| Ranged read | The download contract depends on it. |
|
||||
|
||||
## Running the certification
|
||||
|
||||
```bash
|
||||
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
kubectl logs job/fileserver-pvc-certification
|
||||
```
|
||||
|
||||
The job writes a machine-readable result to the claim itself, carrying the full tuple:
|
||||
|
||||
```json
|
||||
{
|
||||
"kubernetesVersion": "...",
|
||||
"csiDriver": "...",
|
||||
"storageClass": "...",
|
||||
"accessMode": "ReadWriteOnce",
|
||||
"backend": "ext2/ext3",
|
||||
"mountOptions": "rw,relatime",
|
||||
"atomicMove": true,
|
||||
"sameFileStore": true,
|
||||
"atomicCreate": true
|
||||
}
|
||||
```
|
||||
|
||||
The job fails closed: a volume whose staging and content areas are on different stores is not
|
||||
certified, because its publish would silently degrade to a copy.
|
||||
|
||||
## Network filesystems
|
||||
|
||||
```bash
|
||||
docker compose -f infra/fileserver/nfs/compose.yml up -d
|
||||
FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
|
||||
```
|
||||
|
||||
The mount is `hard`, deliberately. A `soft` mount converts a slow server into a short write, which
|
||||
is exactly the corruption this design refuses to accept.
|
||||
|
||||
## Startup enforcement
|
||||
|
||||
`FileserverStartupValidator` re-runs the probe at boot and refuses to accept traffic when a required
|
||||
capability is missing — `ATOMIC_MOVE_REQUIRED` on a filesystem that cannot prove an atomic move
|
||||
fails closed rather than degrading silently.
|
||||
|
||||
## Adding a new store
|
||||
|
||||
Extend `ContentStoreContract` and pass it. A prose claim of compatibility is not accepted; the
|
||||
contract is executable precisely so a future object-storage adapter has to demonstrate the same
|
||||
offset, digest, and create-only behaviour the local store does.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Fileserver support matrix
|
||||
|
||||
A support level here is a claim about evidence, not about intent. Every row names the CI job that
|
||||
produces that evidence; `FileserverDocumentationCoverageTest` fails the build if a row names a job
|
||||
that does not exist, so a level can never outlive the test that justified it.
|
||||
|
||||
## Levels
|
||||
|
||||
| Level | What it means |
|
||||
|---|---|
|
||||
| Stable | Certified on every pull request. Contract changes are breaking changes. |
|
||||
| Beta | Certified nightly. The contract may still change with a deprecation notice. |
|
||||
| Limited | Certified on the release gate only, under stated constraints. |
|
||||
| Compatibility | Accepted but not optimized; known caveats are listed inline. |
|
||||
| Experimental | Off by default, unratified upstream, may change without notice. |
|
||||
|
||||
## Runtime profiles
|
||||
|
||||
| Profile | Level | CI job |
|
||||
|---|---|---|
|
||||
| Local filesystem (ext4) content store | Stable | `fileserver-local-ext4-contract` |
|
||||
| Spring MVC transport (raw, multipart, batch, download) | Stable | `fileserver-http-contract` |
|
||||
| Spring WebFlux transport | Experimental | `fileserver-http-contract` |
|
||||
| Path, filename, range, and problem-detail hardening | Stable | `fileserver-security-suite` |
|
||||
| Bounded-memory transfer | Stable | `fileserver-bounded-memory` |
|
||||
| Application and architecture invariants | Stable | `fileserver-unit-and-architecture` |
|
||||
| Runtime assembly (the capability starts with the flag on) | Stable | `fileserver-unit-and-architecture` |
|
||||
| tus 1.0 resumable uploads | Stable | `fileserver-http-contract` |
|
||||
| Crash-recovery matrix | Beta | `fileserver-process-kill-matrix` |
|
||||
| NFSv4 ambiguity handling | Beta | `fileserver-nfs-ambiguity` |
|
||||
| Large-file and slow-client performance | Beta | `fileserver-large-file-performance` |
|
||||
| Multi-instance writer lease | Beta | `fileserver-multi-instance-lease` |
|
||||
| Kubernetes ReadWriteOnce PVC | Limited | `fileserver-pvc-certification` (manifest checks in CI; cluster run is operator-driven) |
|
||||
| Nginx `X-Accel-Redirect` delegation | Limited | `fileserver-http-contract` |
|
||||
| Telemetry sensitive-data suppression | Stable | `fileserver-sensitive-telemetry-scan` |
|
||||
| Documentation and support-claim coverage | Stable | `fileserver-documentation-gate` |
|
||||
| Full release verification | Stable | `fileserver-full-verification` |
|
||||
| HTTP resumable uploads draft-12 | Experimental | `fileserver-http-contract` |
|
||||
|
||||
### Why WebFlux is Experimental, not Stable
|
||||
|
||||
The reactive router, handlers and readers are now wired: `FileserverReactiveConfiguration`
|
||||
contributes the scheduler, the handlers and a `RouterFunction` bean under
|
||||
`@ConditionalOnWebApplication(type = REACTIVE)` plus the platform master switch. Previously nothing
|
||||
built them at all, so "Stable" described the source tree rather than a running server.
|
||||
|
||||
It stays `Experimental` because the shipped composition cannot select it. `adapter:inbound:web`
|
||||
also puts `DispatcherServlet` on the classpath — deliberately, so adding `spring-webflux` does not
|
||||
drag a second embedded server onto the runtime — and Boot's application-type deduction therefore
|
||||
resolves SERVLET. A fork that removes the servlet stack and adds a reactive server gets working
|
||||
routes without editing any Fileserver code; the shipped template does not exercise that path.
|
||||
|
||||
Raising it to Stable requires a contract job that drives the routes over a running reactive server
|
||||
rather than through direct construction.
|
||||
|
||||
## Explicitly not claimed
|
||||
|
||||
These have no job, and therefore no claim:
|
||||
|
||||
- An automated Kubernetes cluster result. `fileserver-pvc-certification` validates the manifest on
|
||||
every release and applies it only when a release cluster is configured; without one it warns and
|
||||
records that nothing was certified. The cluster tuple is produced by an operator and read from
|
||||
[storage-certification.md](storage-certification.md).
|
||||
|
||||
- Kubernetes ReadWriteMany PVC. Concurrent writers across nodes are not certified.
|
||||
- Windows NTFS as a production storage root. The filename policy strips the characters NTFS
|
||||
reserves, but no job certifies the publish path there.
|
||||
- Object storage as a content store. The contract exists (`ContentStoreContract`) but no adapter
|
||||
implements it yet.
|
||||
- Server-side malware scanning. The verification pipeline has the port and the verdict precedence;
|
||||
no scanner is shipped.
|
||||
|
||||
## Where the rest is written down
|
||||
|
||||
- [configuration.md](configuration.md) — every `app.fileserver-platform.*` key, its default, and the
|
||||
conditions that fail startup rather than degrade.
|
||||
- [design-deviations.md](design-deviations.md) — where the implementation departs from the frozen
|
||||
design, why, and the test that pins each decision.
|
||||
- [http-contract.md](http-contract.md) — the wire contract.
|
||||
- [security.md](security.md) — the threat model and what enforces each control.
|
||||
- [operations.md](operations.md) — runbooks, each starting from a metric.
|
||||
- [storage-certification.md](storage-certification.md) — how a volume is certified.
|
||||
- [upgrade-guide.md](upgrade-guide.md) — what changes between versions.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Fileserver upgrade guide
|
||||
|
||||
## Enabling the capability
|
||||
|
||||
The Fileserver ships off. Nothing is registered — no endpoint, no thread pool, no metric — until it
|
||||
is enabled explicitly.
|
||||
|
||||
```yaml
|
||||
ca-skeleton:
|
||||
fileserver:
|
||||
enabled: true
|
||||
instance-id: ${HOSTNAME}
|
||||
default-namespace: default
|
||||
observability:
|
||||
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||
```
|
||||
|
||||
`instance-id` must be unique per instance: it is the writer-lease owner, and two nodes sharing one
|
||||
would both believe they hold the same lease.
|
||||
|
||||
`fingerprint-key` is required and has no default. Startup fails without it rather than falling back
|
||||
to an unkeyed digest, which would be reversible for an enumerable identifier space.
|
||||
|
||||
## Optional surfaces
|
||||
|
||||
Each is a separate switch, and each defaults to off:
|
||||
|
||||
```yaml
|
||||
ca-skeleton:
|
||||
fileserver:
|
||||
admin:
|
||||
enabled: false # management plane; intended for a management port
|
||||
tus:
|
||||
enabled: false # tus 1.0 Stable
|
||||
httpbis-draft12:
|
||||
enabled: false # Experimental; unratified, may change without notice
|
||||
nginx:
|
||||
enabled: false # front-proxy delegation; needs a validated internal location
|
||||
```
|
||||
|
||||
## Database schema
|
||||
|
||||
The metadata schema is installed as a capability migration and starts inactive:
|
||||
|
||||
```
|
||||
V1__create_fileserver_metadata.sql → capability_schema_registry: jpa-fileserver-metadata-v1
|
||||
```
|
||||
|
||||
Activate it deliberately. Enabling the capability without an activated schema fails at startup
|
||||
rather than at the first upload.
|
||||
|
||||
## Choosing a publish mode
|
||||
|
||||
| Mode | When |
|
||||
|---|---|
|
||||
| `atomic-move-preferred` | Default. Uses an atomic rename when the probe proves one, else a metadata pointer. |
|
||||
| `atomic-move-required` | Fail closed. Refuses to start on storage that cannot prove an atomic move. |
|
||||
| `metadata-pointer` | For storage without atomic rename; publication is the metadata commit. |
|
||||
|
||||
Pick `atomic-move-required` when the storage is certified and you want a misconfiguration to surface
|
||||
at boot rather than at publish time.
|
||||
|
||||
## Behaviour that will surprise you
|
||||
|
||||
- **A delete answers `202`, not `204`, when content still exists.** The file is already unreadable;
|
||||
the physical reclaim is deferred. Treating `202` as a failure will produce spurious retries.
|
||||
- **A batch upload answers `200` even when parts failed.** The batch is explicitly non-atomic, and a
|
||||
single status could not report a partial outcome honestly. Read `results[].problem`.
|
||||
- **An ambiguous failure must not be retried.** Check `"ambiguous": true` in the problem document.
|
||||
- **`If-Match` takes the strong ETag, not a version number.** A client can only assert about the
|
||||
representation it was actually served.
|
||||
- **Inline rendering is refused for scriptable types** even when the caller asks for it.
|
||||
|
||||
## Verifying an upgrade
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :application-core:check :adapter:inbound:web:check \
|
||||
:adapter:outbound:fileserver:check --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*Fileserver*' --console=plain
|
||||
```
|
||||
@@ -0,0 +1,121 @@
|
||||
# HTTP Client Platform — Operations Runbook
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| `http.client.requests` | Physical attempt timer (Spring standard name, kept deliberately) |
|
||||
| `http.client.logical.calls` | User-visible logical call timer |
|
||||
| `http.client.attempts` | Attempt counter |
|
||||
| `http.client.retry.count` | Retries by reason |
|
||||
| `http.client.retry.exhausted` | Retry budget exhausted |
|
||||
| `http.client.ambiguous` | Ambiguous outcomes |
|
||||
| `http.client.timeout` | Timeouts by stage |
|
||||
| `http.client.request.bytes` | Request wire bytes |
|
||||
| `http.client.response.bytes` | Response bytes |
|
||||
| `http.client.active` | In-flight attempts |
|
||||
| `http.client.pool.connections` | Leased and available connections |
|
||||
| `http.client.pool.pending` | Pool waiters |
|
||||
| `http.client.pool.acquire.duration` | Pool wait time |
|
||||
| `http.client.dns.duration` | DNS time |
|
||||
| `http.client.connect.duration` | Connect time |
|
||||
| `http.client.tls.duration` | TLS time |
|
||||
| `http.client.circuit.state` | Circuit state |
|
||||
| `http.client.bulkhead.rejected` | Bulkhead rejections |
|
||||
| `http.client.rate_limit.rejected` | Local rate-limit rejections |
|
||||
| `http.client.oauth.refresh` | Token refresh outcomes |
|
||||
| `http.client.ssrf.rejected` | Dynamic target rejections |
|
||||
|
||||
`http.client.requests` counts attempts and `http.client.logical.calls` counts user calls. When they
|
||||
diverge, retries are absorbing failures — which is the first thing to look at during an incident.
|
||||
|
||||
## Reading an incident
|
||||
|
||||
| Symptom | Likely cause | Where to look |
|
||||
|---|---|---|
|
||||
| logical calls fine, attempts spiking | upstream degraded, retries absorbing it | `http.client.retry.count` by reason |
|
||||
| `http.client.ambiguous` non-zero | non-idempotent writes reaching `SENT_NO_RESPONSE` | reconcile with the upstream; consider an idempotency key |
|
||||
| pool pending climbing | pool too small or upstream slow | `http.client.pool.acquire.duration`, `pool.connections` |
|
||||
| circuit open | sustained upstream failure | `http.client.circuit.state`; local rejections do not open it |
|
||||
| `http.client.ssrf.rejected` non-zero | a caller is submitting internal URLs | Dynamic Target policy and audit trail |
|
||||
|
||||
## Actuator
|
||||
|
||||
`GET /actuator/httpclients` reports profile name, runtime generation, state, transport, API,
|
||||
protocols, active leases, pool ceiling, credential type, TLS profile id, redirect flag, retry policy,
|
||||
and capability warnings. Base URL, credentials, trust store paths, and resolved IPs are deliberately
|
||||
absent: an actuator endpoint is reachable by more people than a secret store is.
|
||||
|
||||
## Rotation
|
||||
|
||||
Certificates and secrets rotate by building a new runtime generation and swapping the registry
|
||||
pointer, never by mutating a live client. A connection pool holds sockets established under the
|
||||
previous identity, so replacing material without replacing the pool leaves live connections
|
||||
authenticated by a certificate that is meant to be gone.
|
||||
|
||||
```text
|
||||
build new generation → validate → atomic swap → new calls use it
|
||||
old generation → DRAINING → in-flight calls finish → no new retries → forced close at the drain deadline
|
||||
```
|
||||
|
||||
## Shutdown
|
||||
|
||||
```text
|
||||
RUNNING → DRAINING
|
||||
new logical calls refused or routed to the new generation
|
||||
in-flight attempts complete
|
||||
new retries refused
|
||||
shutdown timeout
|
||||
remaining calls cancelled
|
||||
pool closed
|
||||
```
|
||||
|
||||
## Retry ownership
|
||||
|
||||
Exactly one of the application client, an external SDK, or the service mesh may own retries.
|
||||
Two owners multiply traffic during an incident. Record the owner per upstream and check it whenever
|
||||
a mesh retry policy changes.
|
||||
|
||||
## Error model
|
||||
|
||||
Every outbound failure is one of these stable types. The type is derived from the classified failure
|
||||
category, not from whatever the engine happened to throw, so it means the same thing on Apache, JDK,
|
||||
and Reactor Netty. Each carries `HttpFailureMetadata`: client, operation, method, URI **template**,
|
||||
evidence, replayability, stage, retryability, attempt, elapsed, remaining deadline, status, trace id
|
||||
— and nothing else.
|
||||
|
||||
| Exception | Raised when | Retryable |
|
||||
|---|---|---|
|
||||
| `HttpConfigurationException` | profile, operation, or capability configuration is invalid | never |
|
||||
| `HttpTargetRejectedException` | target URI, host, port, header, or address policy refused the request | never |
|
||||
| `HttpDnsException` | hostname resolution failed or timed out | yes, inside budget |
|
||||
| `HttpPoolAcquireTimeoutException` | no connection or stream within the pending-acquire budget | yes, inside budget |
|
||||
| `HttpConnectException` | socket connect failed | yes, inside budget |
|
||||
| `HttpProxyException` | proxy connect, CONNECT tunnel, or proxy auth failed | yes, inside budget |
|
||||
| `HttpTlsException` | TLS handshake failed | only a transient handshake timeout |
|
||||
| `HttpRequestWriteException` | request headers or body could not be fully written | only when safely idempotent |
|
||||
| `HttpResponseTimeoutException` | final headers or a body chunk did not arrive in time | only when safely idempotent |
|
||||
| `HttpResponseTruncatedException` | the response ended before the body was complete | only when safely idempotent and undelivered |
|
||||
| `HttpRemoteErrorException` | non-success status without a problem document | per the status rules |
|
||||
| `HttpProblemDetailException` | non-success status with a bounded RFC 9457 document | per the status rules |
|
||||
| `HttpRedirectRejectedException` | a hop violated hop count, origin, method, or replay policy | never |
|
||||
| `HttpAuthenticationException` | credential materialization or refresh failed | never |
|
||||
| `HttpSerializationException` | request encoding or response decoding failed | never |
|
||||
| `HttpResponseTooLargeException` | wire or decoded bytes exceeded the profile limit | never |
|
||||
| `HttpDeadlineExceededException` | the effective deadline was reached | never |
|
||||
| `HttpCircuitOpenException` | the upstream circuit is open | never |
|
||||
| `HttpBulkheadRejectedException` | no attempt or logical admission permit was available | never |
|
||||
| `HttpRateLimitRejectedException` | the local attempt rate limit or retry budget rejected the attempt | never |
|
||||
| `HttpAmbiguousExecutionException` | a non-idempotent request was sent and the outcome is unknown | never — reconcile instead |
|
||||
|
||||
## Traces
|
||||
|
||||
```text
|
||||
http.client.operation logical internal span
|
||||
└─ http.client.request attempt 1 CLIENT span
|
||||
└─ http.client.request attempt 2 CLIENT span
|
||||
```
|
||||
|
||||
W3C Trace Context is propagated with a Baggage allowlist. Dynamic Targets do not propagate trace
|
||||
context by default. Retry reason and evidence are recorded as span events; credentials and remote
|
||||
error bodies are never recorded as attributes.
|
||||
@@ -0,0 +1,55 @@
|
||||
# HTTP Client Platform — Performance Baseline
|
||||
|
||||
The certification lane asserts **resource bounds**, not throughput targets. Its purpose is to prove
|
||||
that a failing upstream, a large body, or a rotation cannot consume unbounded memory, connections,
|
||||
threads, or upstream traffic. Nothing here becomes a runtime adaptive default: every bound comes
|
||||
from an explicit profile setting.
|
||||
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
# structural bounds only (default; still executes every test)
|
||||
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --console=plain
|
||||
|
||||
# full certification, including machine-dependent bounds
|
||||
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||
-Pperformance.assertions.enabled=true --console=plain
|
||||
|
||||
# JMH benchmarks
|
||||
./gradlew :adapter:outbound:httpclient:jmh --console=plain
|
||||
```
|
||||
|
||||
Machine-dependent assertions are reported as explicitly skipped when the flag is absent — the lane
|
||||
never silently degrades into a pass.
|
||||
|
||||
## Certified bounds
|
||||
|
||||
| Test | Bound | Kind |
|
||||
|---|---|---|
|
||||
| `RetryStormBudgetTest` | 10 000 logical calls against a failing upstream produce at most 11 000 physical attempts at a 10 % budget | structural |
|
||||
| `LargeBodyResourceTest` | a 32 MiB streaming download consumes every byte without buffering the payload on the heap | structural + machine-dependent heap bound |
|
||||
| `PoolSaturationPerformanceTest` | 24 concurrent calls against a 4-connection pool all reach a terminal outcome; none hang | structural |
|
||||
| `Http2StreamSaturationTest` | 32 concurrent reactive streams share a 2-connection pool and complete | structural |
|
||||
| `OAuthRefreshContentionTest` | 100 genuinely concurrent callers produce exactly one token request | structural |
|
||||
| `RuntimeRotationDrainTest` | 50 rotations close all 50 retired generations and leave no drain thread | structural |
|
||||
|
||||
## Recording a baseline
|
||||
|
||||
When certifying a deployment, record alongside the numbers: the exact command, the commit, hardware,
|
||||
JVM flags, the profile YAML under test, p50/p95/p99/max, peak heap, peak direct memory, thread count,
|
||||
connection count, physical attempt count, and error count. A latency figure without its profile and
|
||||
hardware is not a baseline; it is an anecdote.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Command | _fill in at certification time_ |
|
||||
| Commit | _fill in_ |
|
||||
| Hardware / JVM | _fill in_ |
|
||||
| Profile under test | _fill in_ |
|
||||
| p50 / p95 / p99 / max | _fill in_ |
|
||||
| Peak heap / direct memory | _fill in_ |
|
||||
| Threads / connections | _fill in_ |
|
||||
| Physical attempts / errors | _fill in_ |
|
||||
|
||||
The table is intentionally left unfilled in the repository: publishing numbers measured on a build
|
||||
agent as if they were a certified baseline would be worse than having none.
|
||||
@@ -0,0 +1,47 @@
|
||||
# HTTP Client Platform — Release Checklist
|
||||
|
||||
A release is complete when each item below is demonstrated by a command, not by review.
|
||||
|
||||
## Gates
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:httpclient:test --console=plain
|
||||
./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --console=plain
|
||||
./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --console=plain
|
||||
./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --console=plain
|
||||
./gradlew :adapter:outbound:httpclient:spring62CompatibilityTest --console=plain
|
||||
./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --console=plain
|
||||
./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --console=plain # needs Docker
|
||||
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||
-Pperformance.assertions.enabled=true --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
python3 ../scripts/verify-httpclient-docs.py
|
||||
```
|
||||
|
||||
## Completion criteria (design §33)
|
||||
|
||||
- [ ] Typed clients are the default entry point; H2 and H3 are separately authorised.
|
||||
- [ ] H1–H4 cannot bypass timeout, host, TLS, auth, size, or observation policy.
|
||||
- [ ] Apache, JDK, and Reactor produce identical result and exception metadata.
|
||||
- [ ] Pool, DNS, connect, TLS, and retry backoff all fit inside the effective deadline.
|
||||
- [ ] Every extra attempt is explained by idempotency, replayability, evidence, deadline, and budget.
|
||||
- [ ] Non-idempotent `SENT_NO_RESPONSE` surfaces as `HttpAmbiguousExecutionException`.
|
||||
- [ ] Pool and buffers are reclaimed after unread bodies, decode errors, cancels, and size rejections.
|
||||
- [ ] OAuth2 refresh is single-flight and 401 replay happens at most once.
|
||||
- [ ] Trust-all and hostname-verification bypass fail at startup.
|
||||
- [ ] Canonicalisation, DNS/IP validation, redirect revalidation, and egress control all pass.
|
||||
- [ ] No transparent retry occurs after the first delivered byte.
|
||||
- [ ] No platform code blocks a Reactor event loop, proven by a BlockHound self-check.
|
||||
- [ ] The negotiated wire protocol matches what the support matrix claims per transport.
|
||||
- [ ] Logical calls and attempts are separate metrics with no forbidden label.
|
||||
- [ ] DNS, pool, TLS, reset, partial response, and HTTP/2 GOAWAY are reproducible.
|
||||
- [ ] Thread, heap, direct memory, pool, and retry budget bounds hold.
|
||||
- [ ] The support matrix, configuration reference, security guide, runbook, and migration guide match the code.
|
||||
|
||||
## Experimental
|
||||
|
||||
Jetty HTTP/3 stays Experimental until `Http3CapabilityReport` reports QUIC and TLS 1.3 and the
|
||||
contract subset it declares passes in a dedicated environment. It is never auto-configured by the
|
||||
Stable starter.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Retry and Ambiguity
|
||||
|
||||
The platform never decides a retry from the HTTP method alone (design D-09). A second attempt
|
||||
happens only when idempotency, body replayability, execution evidence, deadline, and retry budget
|
||||
all permit it.
|
||||
|
||||
## Execution evidence
|
||||
|
||||
| Evidence | Meaning | Typical cause |
|
||||
|---|---|---|
|
||||
| `NOT_SENT` | Proven that the server never received the request | profile rejection, pool timeout, DNS failure, connect failure, pre-request TLS failure, HTTP/2 `REFUSED_STREAM` |
|
||||
| `SENT_NO_RESPONSE` | Some or all of the request was written, no final header arrived | partial write, response-header timeout, connection reset |
|
||||
| `RESPONSE_RECEIVED` | Final headers arrived, whatever the status | 2xx, 4xx, 5xx, redirect |
|
||||
| `PARTIAL_RESPONSE` | Headers and part of the body arrived | reset during decode, interrupted stream |
|
||||
|
||||
`NOT_SENT` is only produced by a stage failure that proves it. A generic engine I/O error is never
|
||||
upgraded to `NOT_SENT`, because that is exactly how a timeout becomes a duplicate payment.
|
||||
|
||||
## Body replayability
|
||||
|
||||
| Body | Replayability |
|
||||
|---|---|
|
||||
| immutable `byte[]` | `REPLAYABLE` |
|
||||
| DTO plus a deterministic codec | `REPLAYABLE` |
|
||||
| reopenable file or resource supplier | `REOPENABLE` |
|
||||
| a single `InputStream` instance | `ONE_SHOT` |
|
||||
| publisher factory | as declared |
|
||||
| publisher instance | `ONE_SHOT` |
|
||||
| multipart | the weakest part |
|
||||
|
||||
## Decision order
|
||||
|
||||
`DefaultRetryEligibilityEngine` evaluates in this order, and a later rule can never re-enable
|
||||
something an earlier one forbade:
|
||||
|
||||
1. attempts exhausted → `RetryDenied.maxAttempts()`
|
||||
2. retry budget empty → `RetryDenied.budgetExhausted()`
|
||||
3. body not replayable → `RetryDenied.bodyNotReplayable()`
|
||||
4. first byte already delivered → `RetryDenied.responseAlreadyDelivered()`
|
||||
5. runtime draining → `RetryDenied.runtimeDraining()`
|
||||
6. remaining deadline below the minimum attempt budget → `RetryDenied.deadline()`
|
||||
7. permanent failure category → `RetryDenied.permanentFailure(...)`
|
||||
8. `SENT_NO_RESPONSE` on an operation that is not safely idempotent → `AmbiguousFailure`
|
||||
9. status- and failure-specific rules
|
||||
|
||||
## Status rules
|
||||
|
||||
| Status | Decision |
|
||||
|---|---|
|
||||
| 408 | retry inside deadline and budget |
|
||||
| 425 | at most one retry, first attempt only |
|
||||
| 429 | retry inside `Retry-After`, deadline, and budget |
|
||||
| 401 | one refresh-and-replay, safe replayable operations only |
|
||||
| 500 | denied unless the upstream registered it as transient **and** the operation is safely idempotent |
|
||||
| 502, 503, 504 | retry for safely idempotent operations; ambiguous otherwise |
|
||||
| other 4xx | denied |
|
||||
|
||||
## Ambiguity
|
||||
|
||||
A non-idempotent request that reached `SENT_NO_RESPONSE` raises
|
||||
`HttpAmbiguousExecutionException`. It is a third answer on purpose: retrying may duplicate a side
|
||||
effect, and reporting a plain failure would tell the caller the request did not happen, which may
|
||||
be false. The caller reconciles, usually by querying the upstream or replaying with an idempotency
|
||||
key.
|
||||
|
||||
## Budget and backoff
|
||||
|
||||
Retry tokens come from a per-upstream token bucket sized as a fraction of real traffic, so a failing
|
||||
upstream cannot be flooded by retries from a healthy fleet. Backoff is exponential with full or
|
||||
decorrelated jitter, bounded by `max-backoff`, by `Retry-After`, and by the remaining deadline. No
|
||||
connection and no bulkhead permit is held while a backoff is waiting.
|
||||
@@ -0,0 +1,75 @@
|
||||
# HTTP Client Platform — Security Guide
|
||||
|
||||
## What the platform owns
|
||||
|
||||
`Authorization`, `Proxy-Authorization`, `Host`, `Content-Length`, `Transfer-Encoding`,
|
||||
`Traceparent`, `Tracestate`, `Baggage`, and (unless a profile opts in) `Cookie` are platform-owned.
|
||||
A caller cannot set them. `Idempotency-Key` is accepted only when the operation declares it. Any
|
||||
header name or value containing CR or LF is rejected before the request is built.
|
||||
|
||||
## Target policy
|
||||
|
||||
A trusted profile accepts only a profile-relative URI template. An absolute URI is rejected rather
|
||||
than sanitised: varying the destination is what H3 is for, and H3 has its own policy, credentials,
|
||||
and address validation. Template variables are encoded per component, so a value containing `/`,
|
||||
`?`, or `#` cannot change the shape of the request.
|
||||
|
||||
## TLS
|
||||
|
||||
Allowed: TLS 1.2 and 1.3, hostname verification, the JVM trust store, a per-profile custom CA, a
|
||||
per-profile client certificate, mTLS, SNI and ALPN, and certificate rotation through a new runtime
|
||||
generation.
|
||||
|
||||
Forbidden and unrepresentable: a trust-all trust manager, disabled hostname verification, ignoring
|
||||
certificate errors, automatically trusting a production self-signed certificate, falling back to
|
||||
plaintext after an HTTPS failure, and writing key material into configuration or logs.
|
||||
|
||||
Unknown CA, hostname mismatch, expired certificate, revoked certificate, protocol mismatch, and a
|
||||
missing client certificate are permanent. Only a transient handshake timeout may be retried, inside
|
||||
the deadline.
|
||||
|
||||
## Dynamic Target (SSRF)
|
||||
|
||||
Every hop — the first one included — runs the whole flow:
|
||||
|
||||
1. strict URI parse
|
||||
2. scheme allowlist
|
||||
3. reject userinfo and invalid ports
|
||||
4. IDNA-canonicalise the host
|
||||
5. host allowlist or suffix policy
|
||||
6. resolve **every** A and AAAA answer
|
||||
7. normalise each address, including IPv4-mapped IPv6
|
||||
8. reject loopback, link-local, RFC1918, ULA, carrier-grade NAT, unspecified, multicast, cloud
|
||||
metadata, and organisation-defined ranges
|
||||
9. pin the connection to the approved addresses through the same validated resolver
|
||||
10. apply response size and content policy
|
||||
11. repeat for each redirect
|
||||
|
||||
Any forbidden address in the answer set rejects the whole target. Validating only the first answer
|
||||
would let a host that resolves to one public and one private address through.
|
||||
|
||||
Dynamic profiles inherit no API key, OAuth token, Cookie, or default header, and no Cookie jar is
|
||||
created. A specific host may be granted a credential only through an explicitly registered
|
||||
`DynamicCredentialBinding`.
|
||||
|
||||
Application-level validation is not sufficient on its own. A network control — Kubernetes
|
||||
NetworkPolicy, service-mesh egress policy, firewall, or proxy ACL — is an operational completion
|
||||
requirement.
|
||||
|
||||
## Redirects
|
||||
|
||||
Disabled by default. Engine redirect handling is off in every transport so the platform can
|
||||
re-validate each hop. 307 and 308 preserve method and body and are therefore allowed only for a
|
||||
replayable body. Cross-origin hops are refused unless the profile opts in, and when they are
|
||||
allowed `Authorization`, `Proxy-Authorization`, `Cookie`, and API-key headers are stripped.
|
||||
|
||||
## Observability
|
||||
|
||||
Allowed tags: `clientName`, `operationName`, `method`, `uriTemplate`, `status`, `outcome`,
|
||||
`transport`, `protocol`, `timeoutType`, `retryReason`, `evidence`, `circuitState`.
|
||||
|
||||
Rejected outright: full URL, query parameters, path variable values, user ID, raw tenant ID,
|
||||
resolved IP, API key, token, Cookie, idempotency key, request or response body, exception message.
|
||||
|
||||
Failures are logged once, structured, at the end of a logical call. Retry attempts are DEBUG or span
|
||||
events. URLs appear only as templates.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Streaming and Large Bodies
|
||||
|
||||
## Response lifecycle
|
||||
|
||||
A blocking streaming download returns `BlockingStreamingResponse`, never a bare `InputStream`.
|
||||
Closing is idempotent and always releases the connection — after a full read, a partial read, a
|
||||
decode failure, or a size rejection. The status is validated before any body byte is delivered, so a
|
||||
failed download never becomes a half-consumed stream the caller has to reason about.
|
||||
|
||||
A reactive download emits bounded `DataBuffer` values. Buffers are released on completion, error,
|
||||
and cancellation; a dropped buffer is direct memory nobody returns.
|
||||
|
||||
Wire bytes and decoded bytes are bounded independently, because a compressed payload passes a wire
|
||||
check and then expands. Limits are enforced while reading, not after buffering.
|
||||
|
||||
## The first-byte boundary
|
||||
|
||||
```text
|
||||
response headers received
|
||||
→ nothing delivered yet
|
||||
→ a read-only operation may still be retried
|
||||
→ first InputStream read or first Flux onNext
|
||||
→ transparent retry is permanently disabled
|
||||
```
|
||||
|
||||
`FirstByteDeliveryGuard` latches once and never resets. Retrying after delivery would replay a
|
||||
stream the caller has already partly consumed, producing duplicated or reordered data that no
|
||||
downstream code can detect.
|
||||
|
||||
## Request bodies
|
||||
|
||||
A reopenable body is opened once per attempt, which is what makes it replayable; reusing the
|
||||
previous stream would silently send an empty body on the retry. A one-shot stream or publisher
|
||||
instance is never retried. `ReactiveBodySource` takes a publisher *factory* rather than a publisher
|
||||
so a reactive body can honestly declare itself replayable.
|
||||
|
||||
A multipart body is exactly as replayable as its weakest part.
|
||||
|
||||
## Server-sent events
|
||||
|
||||
Three budgets stay separate:
|
||||
|
||||
- `setupDeadline` — establishing the stream
|
||||
- `streamingIdleTimeout` — silence once it is open
|
||||
- `maxStreamDuration` — optional total lifetime
|
||||
|
||||
Applying the request-shaped `total-call` timeout to an SSE subscription would terminate a perfectly
|
||||
healthy stream on schedule, so it is not applied.
|
||||
|
||||
`Last-Event-ID` is opt-in. Replaying from an id is only correct when the producer guarantees it;
|
||||
sending it blindly can skip or duplicate events. Reconnects consume the retry budget like any other
|
||||
physical attempt, and cancelling the subscription stops both the stream and any pending reconnect.
|
||||
@@ -0,0 +1,88 @@
|
||||
# HTTP Client Platform — Support Matrix
|
||||
|
||||
Grades follow design §6 and §29. A row is **Stable** only when the cross-transport contract suite
|
||||
proves it; anything the suite cannot prove is **Experimental** and says so.
|
||||
|
||||
## Spring API
|
||||
|
||||
| API | Grade | Role | Constraint |
|
||||
|---|---|---|---|
|
||||
| `RestClient` | Stable | Blocking execution | Bounded concurrency and an effective deadline are mandatory |
|
||||
| `WebClient` | Stable | Reactive, streaming, SSE | No blocking work on the event loop |
|
||||
| HTTP Service Client (`@HttpExchange`) | Default | Declarative typed client | Operation metadata is mandatory |
|
||||
| `RestTemplate` | Migration only | Moving existing calls | No new profile or feature |
|
||||
| Generic Exchange (H2) | Restricted | Dynamic method, path, body | Base URL and policy are immutable |
|
||||
| Dynamic Target (H3) | Restricted | User-supplied URL | Separate SSRF policy; inherits no credential |
|
||||
| Native engine | Internal | Engine-specific configuration | Never an application-facing API |
|
||||
|
||||
## Transports
|
||||
|
||||
| Transport | Blocking | Reactive | HTTP/1.1 | HTTP/2 | HTTP/3 | Grade | Verified by |
|
||||
|---|---:|---:|---:|---:|---:|---|---|
|
||||
| Apache HttpClient 5 (classic) | yes | no | yes | **no** | no | Stable (blocking default) | `httpClientStableContractTest`, `NegotiatedProtocolContractTest` |
|
||||
| JDK HttpClient | yes | `sendAsync` | yes | yes (TLS/ALPN) | no | Stable (lightweight, blocking HTTP/2) | `NegotiatedProtocolContractTest` |
|
||||
| Reactor Netty | limited | yes | yes | yes | experimental | Stable (reactive default) | `NegotiatedProtocolContractTest` |
|
||||
| Jetty | facade | yes | yes | yes | yes | **Experimental** | `Http3OptInTest` only |
|
||||
| Simple request factory | yes | no | limited | no | no | Local test only | rejected in production by `ClientProfileValidator` |
|
||||
|
||||
### Apache is HTTP/1.1 here, and why
|
||||
|
||||
Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable, and the library is — in its **async**
|
||||
client. Spring's `HttpComponentsClientHttpRequestFactory` drives the **classic** client, which
|
||||
speaks HTTP/1.1 only. `NegotiatedProtocolContractTest` measures this rather than assuming it: the
|
||||
classic client fails outright against a prior-knowledge h2c server.
|
||||
|
||||
So `ApacheBlockingTransportProvider.capabilities()` declares HTTP/1.1, and a profile that pairs
|
||||
Apache with `HTTP_2` is rejected at startup instead of quietly running HTTP/1.1 while this table
|
||||
claims otherwise. **Blocking HTTP/2 is served by the JDK transport**; reactive HTTP/2 by Reactor
|
||||
Netty. Both are measured from the client after a real TLS handshake, not read from configuration.
|
||||
|
||||
The JDK transport declares `routeScopedPool=false`, `boundedPendingAcquireQueue=false`, and
|
||||
`dynamicTargetStable=false`. A profile that needs any of those is rejected at startup rather than
|
||||
served with weaker guarantees. Choosing between Apache and JDK is therefore a real trade: Apache
|
||||
gives route-scoped pooling and Dynamic Target pinning, JDK gives HTTP/2.
|
||||
|
||||
## Capability gates
|
||||
|
||||
| Capability | Gate |
|
||||
|---|---|
|
||||
| Dynamic Target (H3) | Apache and Reactor Netty only; JDK and Jetty are rejected |
|
||||
| HTTP/3 | `experimentalAcknowledgement` must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||
| Cross-origin redirect | opt-in per profile; credentials are stripped on the hop |
|
||||
| Retry | evidence-based; never enabled by HTTP method alone |
|
||||
|
||||
## CI matrix
|
||||
|
||||
| Profile | Frequency | Release gate | Task |
|
||||
|---|---|---|---|
|
||||
| Spring Framework 7.0 (repository baseline) | every PR | required | `spring70CompatibilityTest` |
|
||||
| Spring Framework 6.2 API surface | every PR | required | `spring62CompatibilityTest` |
|
||||
| Apache HC5 + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=apache` |
|
||||
| JDK HttpClient + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=jdk` |
|
||||
| Reactor Netty + WebClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=reactor` |
|
||||
| SSRF / cardinality suite | every PR | required | `httpClientSecurityTest` |
|
||||
| Toxiproxy fault suite | nightly, release | required | `httpClientFailureInjectionTest` |
|
||||
| Event-loop blocking (BlockHound) | every PR | required | `httpClientBlockHoundTest` |
|
||||
| Performance certification | nightly, release | required | `httpClientPerformanceTest -Pperformance.assertions.enabled=true` |
|
||||
| Jetty HTTP/3 | nightly | Experimental, non-blocking | `test -Phttp3.tests.enabled=true` |
|
||||
|
||||
### Known limitation of the Spring 6.2 lane
|
||||
|
||||
This repository's Spring Boot 4.0 baseline pins Spring Framework 7, so a real 6.2 runtime cannot be
|
||||
resolved here. `spring62CompatibilityTest` therefore verifies the **API surface**: the common
|
||||
packages must not reference any Spring 7-only type, and `org.springframework.web.service.registry`
|
||||
is confined to `…httpclient.spring7`. Executing the suite against an actual 6.2 distribution
|
||||
requires a host project on that line. This limitation is stated rather than hidden behind a passing
|
||||
check.
|
||||
|
||||
|
||||
## What the suites do not prove
|
||||
|
||||
Stated so the matrix is read as a measurement rather than an aspiration.
|
||||
|
||||
| Gap | Why | What is proven instead |
|
||||
|---|---|---|
|
||||
| HTTP/2 frame injection (`REFUSED_STREAM`, arbitrary `GOAWAY`) | The fixture server exposes no frame-level control, and a purpose-built h2 server is a larger dependency than the guarantee is worth here | `Http2EvidenceMapperTest` proves the frame → evidence mapping, and `NegotiatedProtocolContractTest` proves h2 is really negotiated |
|
||||
| Netty buffer-leak detection | Netty reports a leak when an unreferenced buffer is collected, which the suite does not force | `NettyLeakDetectionExtension` asserts the PARANOID detector is live and reports nothing; explicit release assertions in the streaming suites are the primary guarantee |
|
||||
| Spring 6.2 runtime | This repository's Boot 4.0 baseline pins Spring 7 | `spring62CompatibilityTest` confines the common packages to the 6.2 API surface |
|
||||
| Performance latency baseline | Numbers measured on a build agent are not a certification | `httpClientPerformanceTest` asserts structural bounds unconditionally; latency and heap bounds run under `-Pperformance.assertions.enabled=true` |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Command policy
|
||||
|
||||
`src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml` is the
|
||||
single source of truth for what this SDK is willing to do with each Redis command. Official server
|
||||
metadata decides what a command *is*; this file decides what we allow.
|
||||
|
||||
A command that is not classified there is refused. Adding a command therefore means editing that
|
||||
file, not writing code — and the edit is where the risk decision is made and reviewed.
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `risk` | required | `R1` routine, `R2` needs an explicit permit, `R3` administrative, `R4` never allowed |
|
||||
| `support` | required | `TYPED`, `ADVANCED_TYPED`, `RAW_ONLY`, `ADMIN_ONLY`, `VERSION_GATED`, `BLOCKED` |
|
||||
| `minimum-version` | `7.2` | lowest server version that carries the command |
|
||||
| `access` | derived from `support` | which ACL account may issue it |
|
||||
| `blocking` | `false` | occupies its connection until the server replies |
|
||||
| `optional-block` | `false` | the command also has a non-blocking form; only `XREAD` and `XREADGROUP` carry it |
|
||||
| `read-only` | `false` | never mutates the dataset |
|
||||
| `retry-safe` | `read-only` | may be retried after a failure that could have reached the server |
|
||||
| `may-be-ambiguous` | `!read-only` | a failure may leave the outcome unknown |
|
||||
| `timeout-profile` | derived | `FAST`, `COLLECTION`, `ADMIN`, `BLOCKING` |
|
||||
| `key-spec` | `1 1 1` | where the keys are, or `none`, or `movable` |
|
||||
| `required-policy` | – | the permit policy an R2 command demands |
|
||||
|
||||
## Rules the catalog enforces
|
||||
|
||||
- An R2 `ADVANCED_TYPED` command must name the permit policy it requires. There is no R2 command
|
||||
that anyone may issue without an issued permit.
|
||||
- An R4 command must be `BLOCKED`, and an R3 command must be `ADMIN_ONLY`. The type system refuses
|
||||
the other combinations at load time.
|
||||
- A `BLOCKED` command carries no ACL account, so no path in the SDK can reach it.
|
||||
- A blocking command must use the `BLOCKING` timeout profile, and its request must declare a bounded
|
||||
server block — unless it also declares `optional-block`, which only the two stream reads do.
|
||||
- Deprecated command names stay `BLOCKED` even when the SDK offers their behaviour. The typed
|
||||
sorted-set ranges issue `ZRANGE ... BYSCORE|BYLEX|REV`, not `ZRANGEBYSCORE`, so what the guard was
|
||||
told and what reaches the wire are the same command.
|
||||
|
||||
## Where each support level is reachable from
|
||||
|
||||
| Support | Reachable from |
|
||||
| --- | --- |
|
||||
| `TYPED` | the typed operations, no permit |
|
||||
| `ADVANCED_TYPED` | the typed operations, with the named permit |
|
||||
| `VERSION_GATED` | a capability bean that exists only when the probe found the feature |
|
||||
| `RAW_ONLY` | `sdk.raw`, and only with a deployment-registered approval |
|
||||
| `ADMIN_ONLY` | `sdk.admin`, read-only diagnostics only |
|
||||
| `BLOCKED` | nowhere |
|
||||
@@ -0,0 +1,75 @@
|
||||
# Operating the Redis SDK
|
||||
|
||||
## What the metrics can and cannot tell you
|
||||
|
||||
Every observation carries the command family, the deployment mode, and latency. None carries a key,
|
||||
a field, a member, or a value — not because they would be large, but because a metric dimension
|
||||
built from caller data is unbounded cardinality and, for most deployments, tenant identity in a
|
||||
dashboard.
|
||||
|
||||
That means you can answer "which command family is slow" and "which one is failing", and you cannot
|
||||
answer "which key is hot" from metrics. Use the admin plane's `SLOWLOG` projection for the first
|
||||
question and `MEMORY USAGE` on a specific key for the second.
|
||||
|
||||
## The failures worth alerting on
|
||||
|
||||
| Signal | What it means | What to do |
|
||||
| --- | --- | --- |
|
||||
| `RedisCommandRejectedException` | the SDK refused before sending | a caller exceeded a declared bound; the reason names which one |
|
||||
| `RedisCrossSlotException` | a multi-key command spans slots | the keys need a shared hash tag |
|
||||
| `RedisAmbiguousExecutionException` | a write may or may not have applied | reconcile; the SDK will not retry it |
|
||||
| `RedisCapabilityUnavailableException` | the server lacks the feature | a capability bean was constructed by hand, or the probe result changed |
|
||||
| `SentinelFailoverObserver.ambiguousWriteCount` | non-idempotent writes lost to a promotion | each one needs reconciling; the count is the workload |
|
||||
| `ClusterTopologyObserver.reshardingObserved` | `ASK`/`TRYAGAIN` seen | a slot migration is in progress; latency will be uneven until it ends |
|
||||
|
||||
## Things the SDK will never do for you
|
||||
|
||||
- Retry a non-idempotent write after a timeout. `ExecutionCertainty.AMBIGUOUS_FAILURE` is reported,
|
||||
not resolved.
|
||||
- Follow a cross-slot multi-key command by splitting it. It is refused instead.
|
||||
- Read a whole collection, stream, or index. Every read declares a bound.
|
||||
- Load a Lua script or a function library at request time. Both are deployment actions.
|
||||
- Send a command it cannot classify.
|
||||
- Tell you that an acknowledged write was lost. See below — this one is not a limitation you can
|
||||
work around in application code.
|
||||
|
||||
## The write loss the client cannot see
|
||||
|
||||
Set these on every Redis node that can ever be a primary:
|
||||
|
||||
```
|
||||
min-replicas-to-write 1
|
||||
min-replicas-max-lag 1
|
||||
```
|
||||
|
||||
Without them a Sentinel promotion silently destroys acknowledged writes, and this is measured, not
|
||||
theoretical. In `LiveRedisSentinelPromotionTest` on the 7.4 lane, Sentinel promoted the replica and
|
||||
did not demote the old primary for **eleven seconds**. The client stayed connected to a primary that
|
||||
had already been replaced, wrote, and was told `+OK` **2,086 times**. Every one of those writes was
|
||||
discarded when the old primary resynced. Exactly one command failed.
|
||||
|
||||
Nothing on the client can detect this. The server answered, so the driver recorded a success, the
|
||||
SDK recorded `CONFIRMED_SUCCESS`, and the caller was told the write landed. No metric here counts
|
||||
it, `SentinelFailoverObserver` cannot count it, and no retry policy helps — there was no failure to
|
||||
react to. A second run of the same promotion produced sixteen thousand writes, **zero** exceptions,
|
||||
and the same silent loss.
|
||||
|
||||
With the two settings, the identical promotion lost **one** write and refused 2,020 with
|
||||
`NOREPLICAS`, which the SDK reports as a definite, non-ambiguous failure the caller can act on. That
|
||||
is the whole difference: an outage you can see instead of data you cannot.
|
||||
|
||||
The residual window is `min-replicas-max-lag` wide and cannot be closed by configuration alone. A
|
||||
write that must survive a promotion under any circumstances needs `WAIT` after it, at the cost of a
|
||||
round trip to the replica — decide that per write, not globally.
|
||||
|
||||
## Blocking work
|
||||
|
||||
Blocking pops and blocking stream reads run on a dedicated connection lane. If those saturate, the
|
||||
symptom is blocking calls timing out while ordinary traffic is healthy — that is the lane doing its
|
||||
job, not a fault. Size the blocking pool to the number of concurrent consumers, not to request rate.
|
||||
|
||||
## Pub/Sub
|
||||
|
||||
At-most-once. A subscriber that reconnects misses whatever arrived while it was gone, and there is
|
||||
no replay. Durable business events belong in a stream with a consumer group, which is at-least-once
|
||||
and therefore requires idempotent consumers.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Redis SDK support matrix
|
||||
|
||||
This file is a gate, not a summary. `RedisSupportMatrixTest` parses the tables below and fails when
|
||||
the SDK grows a package or a capability that is not listed, so a module cannot ship without someone
|
||||
stating its minimum version, its topology support, and what it does not do.
|
||||
|
||||
Design: `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`.
|
||||
Delivery status and the decisions behind each module: `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Minimum Redis | Topology | Risk exposure | Sync | Reactive | Known limitations |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `api` | 7.2 | all | none | n/a | n/a | contract only; no driver types |
|
||||
| `api/key` | 7.2 | all | none | n/a | n/a | slot tags must be low-cardinality |
|
||||
| `api/codec` | 7.2 | all | none | n/a | n/a | no Java native serialization |
|
||||
| `api/command` | 7.2 | all | none | n/a | n/a | permits never widen the ACL account |
|
||||
| `api/error` | 7.2 | all | none | n/a | n/a | failure metadata carries no key or value |
|
||||
| `api/operations` | 7.2 | all | none | n/a | n/a | contract only |
|
||||
| `api/reactive` | 7.2 | all | none | n/a | n/a | Reactor confined to this package |
|
||||
| `lettuce` | 7.2 | all | R1–R2 | yes | yes | pinned to Lettuce 6.8.2 |
|
||||
| `lettuce/codec` | 7.2 | all | none | yes | yes | UTF-8 and byte array codecs only |
|
||||
| `lettuce/command` | 7.2 | all | R1–R2 | yes | yes | policy catalog is the only command authority |
|
||||
| `lettuce/connection` | 7.2 | all | none | yes | yes | five lanes; blocking work never shares the regular lane |
|
||||
| `lettuce/observability` | 7.2 | all | none | yes | yes | command family only, never a key |
|
||||
| `lettuce/operations` | 7.2 | all | R1–R2 | yes | yes | hash field TTL needs 7.4; sharded pub/sub needs 7.0; stream deletion needs 8.2 |
|
||||
| `config` | 7.2 | all | none | n/a | n/a | permit provenance is HMAC-signed per process |
|
||||
| `cluster` | 7.2 | cluster | none | n/a | n/a | slot arithmetic only; no redirect following |
|
||||
| `programmability` | 7.2 | all | R2 | yes | no | transactions never roll back; scripts return one bulk reply; `FUNCTION LOAD` is admin-plane |
|
||||
| `raw` | 7.2 | all | R2 | yes | no | `RAW_ONLY` commands only; movable key specs unapprovable |
|
||||
| `admin` | 7.2 | all | R3 read-only | yes | no | replies are projected; no destructive command exists |
|
||||
| `extensions` | 8.0 | all | none | yes | no | shared command runner; every extension declares its key |
|
||||
| `extensions/json` | 8.0 | all | R1–R2 | yes | no | narrow JSONPath grammar; documents exchanged as text |
|
||||
| `extensions/search` | 8.0 | all | R2 | yes | no | index names namespaced by the SDK; no drop index |
|
||||
| `extensions/timeseries` | 8.0 | all | R1–R2 | yes | no | retention mandatory at creation |
|
||||
| `extensions/probabilistic` | 8.0 | all | R1–R2 | yes | no | every answer is approximate by construction |
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Minimum Redis | Gate | Bean when absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `SHARDED_PUBSUB` | 7.0 | probe and catalog minimum | none |
|
||||
| `FUNCTIONS` | 7.0 | probe and catalog minimum | none |
|
||||
| `HASH_FIELD_EXPIRATION` | 7.4 | probe and catalog minimum | none |
|
||||
| `HASH_FIELD_EXPIRATION_COMBINED` | 8.0 | probe and catalog minimum | none |
|
||||
| `STREAM_ACKNOWLEDGE_DELETE` | 8.2 | probe and catalog minimum | none |
|
||||
| `STREAM_NEGATIVE_ACKNOWLEDGE` | 8.8 | probe and catalog minimum | none, and no bean exists yet |
|
||||
| `JSON` | 8.0 | probe is authoritative | none |
|
||||
| `SEARCH` | 8.0 | probe is authoritative | none |
|
||||
| `TIME_SERIES` | 8.0 | probe is authoritative | none |
|
||||
| `PROBABILISTIC` | 8.0 | probe is authoritative | none |
|
||||
|
||||
## Certified versions
|
||||
|
||||
A version is certified by its lane producing evidence, not by the version number being newer. An
|
||||
evidence claim here must name the test class that produced it; `RedisSupportMatrixTest` fails the
|
||||
build on a row that claims anything else, so "verified" cannot be written into this table without a
|
||||
test behind it.
|
||||
|
||||
All three lanes have now run on 7.4. The other declared versions are declared, not certified:
|
||||
nothing in this repository has executed against 7.2 or 8.2.
|
||||
|
||||
| Topology | Versions declared | Evidence status |
|
||||
| --- | --- | --- |
|
||||
| Standalone | 7.2, 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisGuardrailTest` on 7.4 |
|
||||
| Sentinel | 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisSentinelPromotionTest` on 7.4 |
|
||||
| Cluster | 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisClusterTest` on 7.4 |
|
||||
|
||||
### What the standalone ACL run established
|
||||
|
||||
`RedisTopologyContractTest` runs the four accounts in `infra/redis-sdk/acl` against a live server and
|
||||
asserts that each `CommandAccess` level grants exactly what the command policy catalog says it may
|
||||
issue. Writing it found five defects that no amount of reading the files would have surfaced:
|
||||
|
||||
1. A Redis ACL file accepts neither comments nor line continuations — the original files did not load
|
||||
at all, and the server refused to start.
|
||||
2. The advanced account granted `SMEMBERS` and `SORT`, both `RAW_ONLY` and therefore the raw gateway
|
||||
account's alone.
|
||||
3. The ordinary account granted `SORT_RO` for the same reason.
|
||||
4. The ordinary account could not run `PUBLISH`, `SUBSCRIBE`, or `PING`, all classified `TYPED`.
|
||||
5. The ordinary account could not run `MULTI`, `EXEC`, `UNWATCH`, or `DISCARD`, also `TYPED`.
|
||||
|
||||
6. The admin account was missing twelve read-only diagnostics the catalog exposes — the `OBJECT`,
|
||||
`PUBSUB`, `XINFO`, `FUNCTION LIST`/`STATS`, and `CLUSTER KEYSLOT` subcommands.
|
||||
|
||||
7. The cursor-scan reply budget was sized to the requested `COUNT`, which Redis treats as a hint —
|
||||
a real `HSCAN COUNT 500` came back with 501 entries and the SDK refused a correct reply.
|
||||
|
||||
Points 2 and 3 are the ones that matter: the account is the last enforcement boundary, so an account
|
||||
wider than the catalog silently removes the second control the design relies on.
|
||||
|
||||
### What the standalone guardrail run established
|
||||
|
||||
`LiveRedisGuardrailTest` wires the real guard, catalog, and typed operations to a live server —
|
||||
the first time `LettuceRedisCommandGateway`, the one class that encodes commands, runs under the
|
||||
SDK's own contracts rather than against the in-memory stand-in. It carries the plan's datasets: a
|
||||
value at the 1 MiB ceiling, a hundred-thousand-field hash, hundred-thousand-member set and sorted
|
||||
set, a twenty-thousand-element list, a stream trimmed to 1,000 while twenty thousand entries are
|
||||
appended, and a five-hundred-command batch.
|
||||
|
||||
The assertions are about limits holding, not throughput. A guardrail test that measured absolute
|
||||
speed would fail on a loaded laptop and teach nobody anything.
|
||||
|
||||
### What the Sentinel promotion run established
|
||||
|
||||
`LiveRedisSentinelPromotionTest` forces one real promotion and asserts several independent claims
|
||||
about it. Every write carries a token unique to the run, so the list on the promoted primary is a
|
||||
verbatim record of what happened and each per-call verdict can be checked against it.
|
||||
|
||||
It found the most serious defect in this delivery, and it is not in the SDK's code:
|
||||
|
||||
> **A superseded primary keeps acknowledging writes.** Sentinel promoted the replica at
|
||||
> `05:56:12.503` and did not demote the old primary until `05:56:23.529` — eleven seconds in which
|
||||
> the client, still connected, wrote and was told `+OK` **2,086 times**. Every one of those writes
|
||||
> was discarded when the old primary resynced from the new one. Exactly **one** command failed. No
|
||||
> client-side signal exists for this: the server answered, so the driver, the SDK, and the caller
|
||||
> all correctly recorded a success.
|
||||
|
||||
`SentinelFailoverObserver` counts *ambiguous* writes, and its documentation used to call those "the
|
||||
ones an operator has to reconcile". That was wrong by three orders of magnitude, and the class now
|
||||
says so.
|
||||
|
||||
What closes the window is on the server, not the client. Re-running the identical promotion with
|
||||
`min-replicas-to-write 1` and `min-replicas-max-lag 1` configured cut acknowledged-and-discarded
|
||||
writes from **2,086 to 1**: the orphaned primary refused 2,020 writes with `NOREPLICAS`, which the
|
||||
SDK translates to a definite, non-ambiguous failure the caller can act on. Both settings are now in
|
||||
the lane, and `acknowledgedWriteLossIsBounded` ties the tolerated loss to the configured lag window,
|
||||
so removing them makes the count jump by an order of magnitude and fails the test.
|
||||
|
||||
That assertion then caught a second version of the same mistake within a day of being written. The
|
||||
first guarded run passed; the second failed with 2,099 lost writes, because the setting had been
|
||||
written into the lane's `primary` service only. The two data nodes swap roles on every failover, so
|
||||
a guardrail applied to whichever one happens to start as primary stops applying the moment the lane
|
||||
does the thing it exists to do. Both nodes now take their whole configuration from one definition.
|
||||
Three consecutive promotions in both directions since: 0, 0, and 1 acknowledged write lost.
|
||||
|
||||
The run also found a translator defect. A promotion closed the channel under an in-flight `RPUSH`
|
||||
and the driver raised a bare `RedisException`, which matched no branch and fell through to a generic
|
||||
failure reported as *definitely did not run*. Nothing about an unrecognised failure supports that
|
||||
claim, and a caller who believes it retries a non-idempotent write. The fallback now treats an
|
||||
unclassified write failure as ambiguous.
|
||||
|
||||
### What the Cluster run established
|
||||
|
||||
`LiveRedisClusterTest` checks the part of `sdk.cluster` that is pure client-side arithmetic against
|
||||
the server that has the last word. The calculator agreed with `CLUSTER KEYSLOT` on every entry of a
|
||||
corpus built from the brace rules a hand-written implementation gets wrong — an empty tag `{}`,
|
||||
`foo{}{bar}`, `foo{{bar}}zap`, an unclosed brace, `}{`, the empty key, and non-ASCII keys — and the
|
||||
rendered-key invariant holds: the slot the SDK computes from a tag alone equals the slot the server
|
||||
computes from the whole rendered key.
|
||||
|
||||
Cross-slot refusal was checked in both directions, because a guard stricter than the cluster costs
|
||||
availability for no reason and a looser one sends requests that cannot succeed. The same key pair
|
||||
the guard refuses is the pair the server answers `CROSSSLOT` for.
|
||||
|
||||
Redirects were observed rather than assumed: a `MOVED` names the slot the client computed, and a
|
||||
slot put into a real `MIGRATING`/`IMPORTING` state answers `ASK` for an absent key and `TRYAGAIN`
|
||||
for a multi-key request that straddles the migration. The lane restores the slot to `STABLE`
|
||||
afterwards, so a run leaves the cluster as it found it.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Redis and client upgrade gate
|
||||
|
||||
Changing the Redis server version or the Lettuce version is not a dependency bump. Both change what
|
||||
commands exist, what they reply, and what an ACL account is allowed to do — all three are things this
|
||||
SDK encodes as fixed decisions. The checks below must pass before either version moves, and each one
|
||||
exists because skipping it produces a specific failure that only shows up in production.
|
||||
|
||||
## 1. Command metadata diff
|
||||
|
||||
Run the catalog drift check against the new server. Every command the server reports must be
|
||||
classified in `src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml`.
|
||||
|
||||
*Why:* an unclassified command is refused by `CommandPolicyGuard`, so a server that grew a command
|
||||
does not create a hole — but a command whose **risk changed upstream** and is still classified R1
|
||||
here does. The diff is what surfaces that.
|
||||
|
||||
## 2. ACL regression
|
||||
|
||||
Re-run `ACL DRYRUN` for every account against every command the SDK can issue, using
|
||||
`RedisAdminOperations.aclDryRun`.
|
||||
|
||||
*Why:* a permit never widens an ACL account, so the account is the last boundary. A new server
|
||||
version that moved a command into a different ACL category silently turns a working call into a
|
||||
runtime refusal on the first request that needs it.
|
||||
|
||||
## 3. Serializer golden bytes
|
||||
|
||||
Compare the encoded form of every registered codec against the stored golden bytes.
|
||||
|
||||
*Why:* a value written by the old version must still decode after the upgrade. A codec change that
|
||||
looks harmless in a round-trip test is not harmless against data already in the instance.
|
||||
|
||||
## 4. Support matrix
|
||||
|
||||
Update `docs/redis/support-matrix.md`. `RedisSupportMatrixTest` fails when a module or capability is
|
||||
missing, and the certified-version table must not claim a version until its topology lane has
|
||||
actually run.
|
||||
|
||||
## 5. Topology suite
|
||||
|
||||
Run the standalone, Sentinel, and Cluster lanes declared in `infra/redis-sdk/`. A version is
|
||||
certified by the lane passing, not by the version number being newer.
|
||||
|
||||
*Why:* failover certainty and cross-slot behaviour are the two things the in-memory fixture cannot
|
||||
prove. `ExecutionCertainty` and `RedisSlotCalculator` are classification and arithmetic; whether the
|
||||
driver actually behaves that way during a promotion or a resharding is only observable on a real
|
||||
topology.
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
Before the upgrade, record the previous server version, the previous Lettuce version, and the
|
||||
`SCRIPT LOAD` digests of every registered script. A rollback is not complete until the digests
|
||||
resolve again on the restored version.
|
||||
|
||||
*Why:* digests are cached per process and invalidated by `SCRIPT FLUSH` and by restarts. A rollback
|
||||
that leaves a process holding digests the restored server does not know produces `NOSCRIPT` on
|
||||
every scripted call until the cache is dropped.
|
||||
|
||||
## What this gate does not cover
|
||||
|
||||
Data migration. Nothing here moves or reshapes stored values; a change that alters what is stored,
|
||||
rather than how it is addressed, needs its own plan.
|
||||
@@ -1,3 +1,7 @@
|
||||
# Repository owner test: dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest
|
||||
# Owner Gradle path: :app-bootstrap:test
|
||||
# Semantic owner test: dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
|
||||
# Semantic owner Gradle path: :adapter:outbound:objectstorage:test
|
||||
schema_version: 1
|
||||
claims:
|
||||
- card_id: object-storage-managed-upload-single
|
||||
|
||||
@@ -176,6 +176,13 @@ Sentinel은 asynchronous replication의 zero-data-loss나 strong consistency를
|
||||
`min-replicas-to-write`, lag bound, replica acknowledgement가 설정돼도 acknowledgement 결과가
|
||||
불명확한 mutation은 여전히 `INDETERMINATE`다.
|
||||
|
||||
`min-replicas-to-write 1` + `min-replicas-max-lag 1`은 선택이 아니라 **필수**다. 미설정 시
|
||||
promotion 중 교체된 구 primary가 계속 `+OK`를 반환하고 그 write는 resync에서 폐기된다. 7.4
|
||||
레인 실측: 승격 후 강등까지 11초, 그 사이 **2,086건이 acknowledge된 뒤 소실**, 실패한 명령은
|
||||
1건. 클라이언트는 이를 감지할 수단이 없다 — 서버가 응답했으므로 driver·SDK·호출자 모두
|
||||
정상 성공으로 기록한다. 설정 후 동일 promotion에서 소실 1건, 나머지 2,020건은 `NOREPLICAS`로
|
||||
명시 거부됐다. 근거: `docs/redis/operations.md`, `LiveRedisSentinelPromotionTest`.
|
||||
|
||||
### Disposable Multipass k3s qualification safety
|
||||
|
||||
qualification lab은 host k3s incident 조치 도구가 아니다. VM exact allowlist는
|
||||
|
||||
@@ -39,4 +39,6 @@ status: <stub|active>
|
||||
> **Note**: This is the canonical runbook template.
|
||||
> Copy this file, rename it to match the `runbook://area/scenario` pattern (→ `area-scenario.md`),
|
||||
> fill in the frontmatter fields, replace section bodies with operational content,
|
||||
> then set `status: active` and remove from `STUB_ALLOWLIST` in `RunbookCoverageContractTest`.
|
||||
> then set `status: active`. `LEGACY_STUB_DEBT` in `RunbookCoverageContractTest` is temporary
|
||||
> containment for existing debt only; do not add a new stub there. Complete the runbook or adopt
|
||||
> the future owned, expiring debt ledger.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
|
||||
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
||||
# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange
|
||||
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
||||
/api/healthcheck
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
# Release Hygiene Refactoring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make every release-hygiene path truthful by fixing the sample-off architecture gate, aligning the Gradle 9.0.0 wrapper and CI validation, making Docker cache stages valid without `.git`, completing SpotBugs analysis classpaths, and removing the observed Gradle 10 deprecation.
|
||||
|
||||
**Architecture:** Leaf-specific architecture rules move to their owning leaf while root tests remain cross-module. Build inputs become explicit: Docker copies registry inputs, evidence-only Git validation executes only in evidence tasks, wrapper bytes/checksums are fixed, and SpotBugs derives auxiliary inputs from the source set it analyzes.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle 9.0.0 Groovy DSL, ArchUnit 1.3.0, SpotBugs Gradle plugin 6.5.6/SpotBugs 4.10.2, Bash, Docker/BuildKit, GitHub Actions.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all 19 leaf identities and production dependency edges from `src/config/architecture/modules.json`.
|
||||
- `domain-core` and `application-core` gain no framework, transport, database, or cloud dependency.
|
||||
- Do not weaken an architecture rule with a global `allowEmptyShould(true)`.
|
||||
- Keep Gradle at exactly `9.0.0` in this plan.
|
||||
- Set `distributionSha256Sum=8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b`.
|
||||
- The official Gradle 9.0.0 wrapper JAR SHA-256 is `76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3`.
|
||||
- Pin `gradle/actions/wrapper-validation` to commit `3f131e8634966bd73d06cc69884922b02e6faf92` in workflows that invoke Gradle.
|
||||
- Docker images do not receive `.git`; full evidence revisions arrive through `-PgitRevision`/CI attestation.
|
||||
- SpotBugs dependency scopes are not widened to silence missing-class output.
|
||||
- Agents do not stage, commit, amend, or push; commit steps from the generic workflow are replaced by diff/status evidence.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Move the Object Storage Architecture Rule to Its Owning Leaf
|
||||
|
||||
**Files:**
|
||||
- Create: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageArchitectureTest.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/build.gradle`
|
||||
- Modify: `src/adapter/outbound/objectstorage/gradle.lockfile`
|
||||
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java:1586-1608`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: production classes under `dev.caskeleton.adapter.outbound.objectstorage..` and application/shared contracts already on the Object Storage test classpath.
|
||||
- Produces: an owner-local ArchUnit rule named `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES`; a sample-off root suite with no Object Storage presence requirement.
|
||||
|
||||
- [ ] **Step 1: Reproduce the existing failing regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :app-bootstrap:sampleOffTest --tests '*CleanArchitectureTest' --console=plain
|
||||
```
|
||||
|
||||
Expected: FAIL only at `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES` because no matching classes are present.
|
||||
|
||||
- [ ] **Step 2: Add the owner-local test before removing the root rule**
|
||||
|
||||
Create a package-local ArchUnit test that imports production classes from the Object Storage package and applies this rule:
|
||||
|
||||
```java
|
||||
@AnalyzeClasses(packages = "dev.caskeleton.adapter.outbound.objectstorage")
|
||||
class ObjectStorageArchitectureTest {
|
||||
@ArchTest
|
||||
static final ArchRule OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES =
|
||||
methods()
|
||||
.that()
|
||||
.areDeclaredInClassesThat()
|
||||
.resideInAPackage("..adapter.outbound.objectstorage..")
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.haveSimpleNameEndingWith("Adapter")
|
||||
.and()
|
||||
.arePublic()
|
||||
.and()
|
||||
.areNotStatic()
|
||||
.should()
|
||||
.notHaveRawReturnType(
|
||||
JavaClass.Predicates.resideInAnyPackage(
|
||||
"..adapter.outbound..",
|
||||
"..adapter.inbound.web..",
|
||||
"..adapter.outbound.persistence.."))
|
||||
.allowEmptyShould(false);
|
||||
}
|
||||
```
|
||||
|
||||
Add the owner-local test dependency:
|
||||
|
||||
```groovy
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
```
|
||||
|
||||
Refresh only the Object Storage leaf lock state with its existing `resolveAndLockAll --write-locks`
|
||||
task. This is a test-scope dependency; do not add a production project or external dependency edge.
|
||||
|
||||
- [ ] **Step 3: Run the owner test while the root regression remains red**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:test --tests '*ObjectStorageArchitectureTest' --console=plain
|
||||
```
|
||||
|
||||
Expected: PASS with matching production adapter methods.
|
||||
|
||||
- [ ] **Step 4: Remove only the misplaced root rule**
|
||||
|
||||
Delete the `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES` field from `CleanArchitectureTest`; do not change neighboring cross-module rules.
|
||||
|
||||
- [ ] **Step 5: Verify both ownership paths**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:test :app-bootstrap:sampleOffTest --console=plain
|
||||
```
|
||||
|
||||
Expected: PASS, zero failed tests.
|
||||
|
||||
- [ ] **Step 6: Record diff evidence without committing**
|
||||
|
||||
Run `git diff --check` and `git status --short`; retain the output for the task review.
|
||||
|
||||
### Task 2: Align and Validate the Gradle 9.0.0 Wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/scripts/verify-gradle-wrapper.sh`
|
||||
- Modify: `src/gradle/wrapper/gradle-wrapper.properties`
|
||||
- Regenerate: `src/gradle/wrapper/gradle-wrapper.jar`, `src/gradlew`, `src/gradlew.bat`
|
||||
- Modify: `.github/workflows/ci-quality-gates.yml`
|
||||
- Modify: `.github/workflows/dependency-vulnerability.yml`
|
||||
- Modify: `.github/workflows/jpa-r2-evidence.yml`
|
||||
- Modify: `.github/workflows/object-storage-qualification.yml`
|
||||
- Modify: `.github/workflows/redis-production-readiness.yml`
|
||||
- Lock without modification: `.github/workflows/link-check.yml`
|
||||
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: repository root as argument 1, wrapper properties/JAR, and every YAML workflow under `.github/workflows`.
|
||||
- Produces: executable `verify-gradle-wrapper.sh` with exit 0 only for the exact Gradle 9.0.0 wrapper, the reviewed six-file workflow path/SHA-256 lock, the repository's restricted canonical workflow grammar, and jobs where an unconditional pinned validation step gates every reachable Gradle invocation.
|
||||
|
||||
- [ ] **Step 1: Write failing executable-contract tests**
|
||||
|
||||
Add a `DeveloperExperienceContractTest` case that runs:
|
||||
|
||||
```java
|
||||
Process process =
|
||||
new ProcessBuilder("bash", ".github/scripts/verify-gradle-wrapper.sh", REPOSITORY_ROOT.toString())
|
||||
.directory(REPOSITORY_ROOT.toFile())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
assertThat(process.waitFor()).as(new String(process.getInputStream().readAllBytes(), UTF_8)).isZero();
|
||||
```
|
||||
|
||||
Add a second case that copies wrapper properties/JAR and workflows to `@TempDir`, changes the distribution checksum, runs the script against that fixture root, and asserts a non-zero exit. The production mutation this test catches is accepting a wrong wrapper or distribution checksum.
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain
|
||||
```
|
||||
|
||||
Expected: FAIL because `.github/scripts/verify-gradle-wrapper.sh` does not exist and the checked-in wrapper is not the Gradle 9.0.0 JAR.
|
||||
|
||||
- [ ] **Step 3: Implement the wrapper verifier**
|
||||
|
||||
The Bash script must:
|
||||
|
||||
```text
|
||||
1. require exactly one repository-root argument;
|
||||
2. require the exact ordered eight-line wrapper-properties file, including the Gradle 9.0.0 URL
|
||||
and distribution checksum from Global Constraints;
|
||||
3. reject duplicate, alternate-separator, escaped, continued, reordered, or extra properties;
|
||||
4. compare the wrapper JAR SHA-256 with the exact Gradle 9.0.0 JAR hash;
|
||||
5. enumerate every top-level `.yml`/`.yaml` workflow, reject symlinks/special files, and compare the
|
||||
exact sorted six-path set and SHA-256 values to the verifier's embedded reviewed workflow lock;
|
||||
additions, removals, renames, or byte changes are failures;
|
||||
6. structurally validate the supported block grammar before admission and emit specific diagnostics
|
||||
for recognized noncanonical `jobs`/job/`steps` containers, flow collections, aliases, anchors,
|
||||
tags, merge keys, encoded or multiline action scalars, and quoted/escaped run scalars; YAML
|
||||
semantics outside this deliberately partial diagnostic parser remain covered by the primary
|
||||
byte lock rather than an overclaim of complete Bash YAML parsing;
|
||||
7. require every Gradle-running job to order checkout, the exact wrapper-validation action with
|
||||
stable `id: gradle-wrapper-validation`, and every Gradle invocation;
|
||||
8. accept the validation step only with its exact canonical name/id/uses fields and no `if`,
|
||||
`continue-on-error`, `with`, `env`, timeout, or other weakening field;
|
||||
9. finalize every Gradle step, not only the first. A Gradle step may have no condition or exactly
|
||||
`${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}`; bare `always()`,
|
||||
failure/cancelled paths, `continue-on-error`, and other reachability expressions fail closed;
|
||||
10. treat literal run-block body text only as shell data, never as an action field, and require each
|
||||
raw Gradle reference admitted by the gate to resolve to a canonical job;
|
||||
11. print `gradle-wrapper-contract: PASS` only when every check succeeds.
|
||||
```
|
||||
|
||||
For an intentional workflow edit, review the complete workflow diff, verify that no workflow path
|
||||
is a symlink/special file, regenerate the entire sorted `sha256sum` list with:
|
||||
|
||||
```bash
|
||||
find .github/workflows -mindepth 1 -maxdepth 1 \
|
||||
\( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
|
||||
find .github/workflows -mindepth 1 -maxdepth 1 -type f \
|
||||
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
||||
| LC_ALL=C sort -z | xargs -0 sha256sum
|
||||
```
|
||||
|
||||
Replace the complete sorted embedded array in the same reviewed change. Never refresh only the
|
||||
failing digest as a build-unblock shortcut.
|
||||
|
||||
- [ ] **Step 4: Regenerate the wrapper twice and add the distribution checksum**
|
||||
|
||||
Run in `src/`:
|
||||
|
||||
```bash
|
||||
./gradlew wrapper --gradle-version 9.0.0 --distribution-type bin
|
||||
./gradlew wrapper --gradle-version 9.0.0 --distribution-type bin
|
||||
```
|
||||
|
||||
Then add the exact `distributionSha256Sum` property immediately after `distributionUrl`.
|
||||
|
||||
- [ ] **Step 5: Add the pinned validation action to every Gradle workflow job**
|
||||
|
||||
After each checkout step and before setup/cache/build invokes Gradle, add:
|
||||
|
||||
```yaml
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
```
|
||||
|
||||
Jobs without a Gradle invocation do not need the action. A sanitizer that intentionally executes
|
||||
after a failed test must use the exact guarded condition shown above so wrapper-validation failure
|
||||
still prevents Gradle. Preserve that behavior in Redis rather than using bare `always()`.
|
||||
|
||||
- [ ] **Step 6: Verify GREEN and mutation rejection**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bash .github/scripts/verify-gradle-wrapper.sh .
|
||||
cd src
|
||||
./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain
|
||||
```
|
||||
|
||||
Expected: script prints `gradle-wrapper-contract: PASS`; focused tests pass; executable mutations
|
||||
reject checksum/property overrides, missing validation per job, named/anonymous/quoted/escaped and
|
||||
continued action variants, encoded run scalars, block/alias/merge/flow YAML forms, validation-step
|
||||
control fields, Gradle steps reachable after validation failure, custom-shell or alternate-wrapper
|
||||
paths, duplicate encoded jobs, workflow additions/removals/symlinks, and otherwise innocuous byte
|
||||
drift through the primary workflow lock.
|
||||
|
||||
- [ ] **Step 7: Record diff evidence without committing**
|
||||
|
||||
Run `sha256sum src/gradle/wrapper/gradle-wrapper.jar`, `git diff --check`, and `git status --short`.
|
||||
|
||||
### Task 3: Make Docker Build Configuration Inputs Explicit
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Dockerfile:39-66`
|
||||
- Modify: `src/Dockerfile.sample:50-75`
|
||||
- Modify: `src/build.gradle:2153-2181` and all Redis evidence consumers
|
||||
- Modify: `src/adapter/outbound/cache-redis/build.gradle` (leaf evidence consumers)
|
||||
- Modify: `src/app-bootstrap/build.gradle`
|
||||
- Modify: `src/sample-portfolio/build.gradle`
|
||||
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `config/**`, Gradle source/build files, `-PgitRevision`, and the `bootJar` archive provider.
|
||||
- Produces: `:app-bootstrap:stageDockerJar` and `:sample-portfolio:stageDockerJar`, each writing exactly `build/docker/application.jar`; evidence metadata is resolved only when a Redis evidence task executes.
|
||||
|
||||
- [ ] **Step 1: Write failing build-contract tests**
|
||||
|
||||
Add tests that split each Dockerfile at its first `RUN ./gradlew` and assert the preceding section
|
||||
uses repository-preserving `WORKDIR /build/src` and contains `COPY config/ ./config/`. Add tests
|
||||
that require the Dockerfiles to run `stageDockerJar` and copy the exact
|
||||
`build/docker/application.jar`, with no `ls | grep | head` selection. Add a test that runs
|
||||
`./gradlew help -PgitRevision=0123456789abcdef0123456789abcdef01234567` from a temporary Git-less
|
||||
copy containing the same files as the dependency-cache stage. Add three self-contained evidence-task
|
||||
fixtures under temporary repository roots: one uses a `.git` directory, one uses a worktree `.git`
|
||||
metadata file, and one uses a dangling `.git` symlink. All prepend a fake `git` to `PATH` and require
|
||||
the exact named failure for `rev-parse` or `status` process errors; the symlink fixture must also prove
|
||||
the link entry exists with `NOFOLLOW_LINKS`. These tests must copy the minimum build/registry inputs
|
||||
and invoke the fixture wrapper; they must not assert or execute the ambient checkout's `.git`.
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain
|
||||
```
|
||||
|
||||
Expected: FAIL because neither cache stage copies `config/**`, both select JARs with shell matching, and Git is resolved during configuration.
|
||||
|
||||
- [ ] **Step 3: Add deterministic Docker staging tasks**
|
||||
|
||||
In both executable modules register:
|
||||
|
||||
```groovy
|
||||
tasks.register('stageDockerJar', Sync) {
|
||||
dependsOn tasks.named('bootJar')
|
||||
from(tasks.named('bootJar').flatMap { it.archiveFile })
|
||||
into(layout.buildDirectory.dir('docker'))
|
||||
rename { 'application.jar' }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update both Dockerfiles**
|
||||
|
||||
Use `WORKDIR /build/src` so repository-relative registry paths resolve under `/build/src/**`, copy
|
||||
`config/` before the first Gradle invocation, invoke the correct `stageDockerJar` task with the
|
||||
existing release/revision properties, and copy only the fixed `build/docker/application.jar` path
|
||||
into the runtime stage.
|
||||
|
||||
- [ ] **Step 5: Move Redis Git evidence resolution to execution time**
|
||||
|
||||
Replace the eager `String` values with closures/providers invoked from evidence task actions:
|
||||
|
||||
```groovy
|
||||
Closure<Map<String, String>> resolveRedisSourceEvidence = {
|
||||
File gitMetadata = rootProject.file('../.git')
|
||||
if (!java.nio.file.Files.exists(
|
||||
gitMetadata.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
|
||||
String attested = providers.gradleProperty('gitRevision')
|
||||
.orElse(providers.environmentVariable('GITHUB_SHA'))
|
||||
.orElse(providers.environmentVariable('GIT_SHA'))
|
||||
.getOrElse('')
|
||||
if (!(attested ==~ /[0-9a-f]{40}/)) {
|
||||
throw new GradleException(
|
||||
'Redis evidence requires an exact 40-character source revision.')
|
||||
}
|
||||
return [revision: attested, treeState: 'ATTESTED']
|
||||
}
|
||||
|
||||
String headFailure = 'Redis evidence failed to resolve checked-out Git HEAD.'
|
||||
def headExecution
|
||||
try {
|
||||
headExecution = providers.exec {
|
||||
commandLine 'git', 'rev-parse', 'HEAD'
|
||||
ignoreExitValue = true
|
||||
}
|
||||
if (headExecution.result.get().exitValue != 0) {
|
||||
throw new GradleException(headFailure)
|
||||
}
|
||||
} catch (GradleException exception) {
|
||||
if (exception.message == headFailure) {
|
||||
throw exception
|
||||
}
|
||||
throw new GradleException(headFailure, exception)
|
||||
}
|
||||
String checkedOut = headExecution.standardOutput.asText.getOrElse('').trim()
|
||||
if (!(checkedOut ==~ /[0-9a-f]{40}/)) {
|
||||
throw new GradleException(headFailure)
|
||||
}
|
||||
String supplied = providers.gradleProperty('gitRevision')
|
||||
.orElse(providers.environmentVariable('GITHUB_SHA'))
|
||||
.orElse(providers.environmentVariable('GIT_SHA'))
|
||||
.orElse(checkedOut)
|
||||
.getOrElse('')
|
||||
if (!(supplied ==~ /[0-9a-f]{40}/)) {
|
||||
throw new GradleException('Redis evidence requires an exact 40-character source revision.')
|
||||
}
|
||||
if (!checkedOut.isBlank() && supplied != checkedOut) {
|
||||
throw new GradleException('Redis evidence source revision does not match checked-out HEAD.')
|
||||
}
|
||||
|
||||
String statusFailure = 'Redis evidence failed to inspect checked-out Git status.'
|
||||
def statusExecution
|
||||
try {
|
||||
statusExecution = providers.exec {
|
||||
commandLine 'git', 'status', '--porcelain', '--untracked-files=normal'
|
||||
ignoreExitValue = true
|
||||
}
|
||||
if (statusExecution.result.get().exitValue != 0) {
|
||||
throw new GradleException(statusFailure)
|
||||
}
|
||||
} catch (GradleException exception) {
|
||||
if (exception.message == statusFailure) {
|
||||
throw exception
|
||||
}
|
||||
throw new GradleException(statusFailure, exception)
|
||||
}
|
||||
String treeState = statusExecution.standardOutput.asText.getOrElse('').isBlank()
|
||||
? 'CLEAN'
|
||||
: 'DIRTY'
|
||||
[revision: supplied, treeState: treeState]
|
||||
}
|
||||
```
|
||||
|
||||
Each evidence-producing root `doLast` and each leaf evidence test's root-suite `afterSuite` resolves
|
||||
this once and uses the returned values for all generated/validated artifacts. The resolver is
|
||||
exposed as `rootProject.ext.resolveRedisSourceEvidence`; eager scalar ext properties are removed.
|
||||
Non-evidence tasks never call the closure. Any repository-root `.git` filesystem entry is detected
|
||||
without following symbolic links, so a directory, worktree metadata file, or dangling symlink always
|
||||
selects the checkout branch. Both Git processes must start, exit zero, and return valid evidence
|
||||
before `CLEAN` or `DIRTY` can be emitted. `ATTESTED` is reserved for a truly absent `.git` entry in
|
||||
an explicitly Git-less build with an exact supplied revision; a Git execution failure must never
|
||||
fall back to it.
|
||||
|
||||
- [ ] **Step 6: Verify GREEN without `.git` and verify evidence mismatch failure**
|
||||
|
||||
Run the focused contract test, `./gradlew help` in the Git-less fixture with a 40-character
|
||||
`gitRevision`, and one Redis evidence task in the real checkout. The Git-less help invocation must
|
||||
pass; a Git-less Redis evidence task with a short revision must fail with the named message. Separate
|
||||
self-contained fixtures must cover a `.git` directory whose `rev-parse` fails, a `.git` worktree file
|
||||
whose `status` fails, and a dangling `.git` symlink whose Git invocation fails. Each fixture must
|
||||
assert the corresponding named fail-closed diagnostic instead of accepting a generic non-zero exit.
|
||||
|
||||
- [ ] **Step 7: Run actual Docker smoke when Docker is available**
|
||||
|
||||
Run both image builds with `--no-cache`. If Docker is unavailable, record the exact blocker and leave these commands as remaining risk; do not claim Docker success from string tests.
|
||||
|
||||
- [ ] **Step 8: Record diff evidence without committing**
|
||||
|
||||
Run `git diff --check` and `git status --short`.
|
||||
|
||||
### Task 4: Complete SpotBugs Auxiliary Classpaths and Remove the Gradle 10 Warning
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/build.gradle:208-360`
|
||||
- Modify: `src/build.gradle:1760-1795`
|
||||
- Test/verify: app-bootstrap redisComposition, inbound GraphQL main, inbound gRPC main SpotBugs tasks
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: every leaf's `SourceSetContainer` and the SpotBugs task named for each source set.
|
||||
- Produces: each SpotBugs task's `auxClassPaths` containing `sourceSet.runtimeClasspath - sourceSet.output` and a required XML report whose analysis errors/missing classes are checked after execution; `verifyApplicationCoreDependencyPurity` uses a configuration-time `Project` reference and declares its execution-time configuration traversal incompatible with the configuration cache.
|
||||
|
||||
- [ ] **Step 1: Capture the failing static-analysis evidence**
|
||||
|
||||
Run clean focused SpotBugs tasks and save output. Expected RED messages name Spring Session, `io.micrometer.context.ContextSnapshot`, and protobuf types as classes needed for analysis.
|
||||
|
||||
- [ ] **Step 2: Capture the Gradle 10 deprecation RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew verifyApplicationCoreDependencyPurity --warning-mode=fail --console=plain
|
||||
```
|
||||
|
||||
Expected: FAIL on execution-time `Task.project` access.
|
||||
|
||||
- [ ] **Step 3: Configure source-set-derived auxiliary classpaths**
|
||||
|
||||
After applying SpotBugs in each leaf, configure:
|
||||
|
||||
```groovy
|
||||
sourceSets.configureEach { sourceSet ->
|
||||
String taskName = "spotbugs${sourceSet.name.capitalize()}"
|
||||
tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) {
|
||||
auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output)
|
||||
def xmlAnalysisReport = reports.maybeCreate('xml')
|
||||
xmlAnalysisReport.required.set(true)
|
||||
doLast {
|
||||
List<String> analysisFailures =
|
||||
spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile)
|
||||
if (!analysisFailures.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${path}: SpotBugs analysis incomplete:\n " +
|
||||
analysisFailures.join('\n '))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not add compile/runtime dependencies solely for SpotBugs. The XML parser fails on a missing or
|
||||
malformed report, malformed `Errors` counts, any `MissingClass`, and any analysis `Error`; ordinary
|
||||
`BugInstance` findings remain governed by the existing main/test severity policy. Wire an
|
||||
executable `verifySpotBugsAnalysisFailureContract` fixture into every leaf `check` so clean and
|
||||
advisory-bug-only reports pass while missing-class and analysis-error reports fail.
|
||||
|
||||
- [ ] **Step 4: Remove execution-time project access**
|
||||
|
||||
Resolve `Project applicationCoreProject = project(':application-core')` before registering
|
||||
`verifyApplicationCoreDependencyPurity`; capture that variable in `doLast` instead of calling
|
||||
`project(...)` from the task action. Because the action still traverses project configurations at
|
||||
execution time, declare
|
||||
`notCompatibleWithConfigurationCache('Inspects project configurations at execution time')` rather
|
||||
than making an unsupported compatibility claim.
|
||||
|
||||
- [ ] **Step 5: Verify GREEN**
|
||||
|
||||
Run `verifySpotBugsAnalysisFailureContract`, the three clean focused SpotBugs tasks, and
|
||||
`verifyApplicationCoreDependencyPurity --warning-mode=fail`. Expected: exit 0, XML
|
||||
`Errors errors="0" missingClasses="0"`, and no missing-analysis-class/deprecation output.
|
||||
|
||||
- [ ] **Step 6: Run release-hygiene aggregate verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew clean check :app-bootstrap:sampleOffTest verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --console=plain --warning-mode=fail
|
||||
cd ..
|
||||
bash .github/scripts/verify-gate-matrix.sh
|
||||
bash .github/scripts/verify-gradle-wrapper.sh .
|
||||
```
|
||||
|
||||
Expected: every command exits 0; no skipped mandatory gate, missing SpotBugs class, or Gradle deprecation.
|
||||
|
||||
- [ ] **Step 7: Record final diff evidence without committing**
|
||||
|
||||
Run `git diff --check`, `git diff --stat`, and `git status --short`. Dispatch the complete diff for architecture/spec and code-quality review.
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
- Spec coverage: every release-hygiene design decision maps to Tasks 1-4.
|
||||
- Type consistency: both executable modules expose the same `stageDockerJar` task and output path; Redis evidence uses one `Map<String,String>` resolver contract.
|
||||
- Architecture: no production dependency edge changes are required.
|
||||
- Test discipline: each behavior has a named failing command or executable mutation fixture before implementation.
|
||||
- Commit policy: all generic commit steps are replaced with diff/status evidence.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Client-Safe Error Boundary Implementation Plan
|
||||
|
||||
> **Execution:** Follow `superpowers:test-driven-development`; request an independent code review
|
||||
> before advancing to the next P1 batch.
|
||||
|
||||
**Goal:** Ensure public HTTP error envelopes contain only allowlisted messages and bounded safe
|
||||
metadata, never raw exceptions or request values.
|
||||
|
||||
**Architecture:** The inbound web adapter maps operational codes to fixed public messages. The
|
||||
sample consumer owns a parallel domain-code mapping. Exception diagnostics stay behind the
|
||||
transport boundary.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 4.0.0, JUnit 6/JUnit Jupiter, AssertJ, MockMvc.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Preserve all completed P0 and verification-purity changes in the dirty worktree.
|
||||
- Preserve every error code/status/category/retryable value.
|
||||
- Preserve safe protocol details and required headers.
|
||||
- Do not leak request DTOs or transport types into application/domain.
|
||||
- Do not stage, commit, amend, or push.
|
||||
|
||||
### Task 1: Operational Handler RED Contracts
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java`
|
||||
- Modify: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/TransportErrorHandlingTest.java`
|
||||
- Create: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/NoResourceFoundErrorHandlingTest.java`
|
||||
|
||||
- [x] Add secret-sentinel tests for mapping, illegal argument, adapter disabled, authentication,
|
||||
authorization, precondition, pagination, and cursor exceptions.
|
||||
- [x] Add validation tests proving rejected values, interpolated/default messages, and iterable
|
||||
keys/indices are absent while normalized fields plus allowlisted reason codes/fixed messages remain.
|
||||
- [x] Add transport tests proving raw request URLs and content-type values are not echoed.
|
||||
- [x] Add a real MVC resource-resolver test for a sentinel-bearing static-resource 404.
|
||||
- [x] Run the focused tests and record RED against the current raw-message implementation (30 tests, 9 expected failures).
|
||||
|
||||
### Task 2: Operational Allowlist Implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java`
|
||||
- Create: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeValidationDetails.java`
|
||||
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java`
|
||||
- Modify: `src/adapter/inbound/web/README.md`
|
||||
|
||||
- [x] Add code-specific fixed operational messages with a safe category fallback.
|
||||
- [x] Replace every public `ex.getMessage()`/rejected-value/raw-URL path.
|
||||
- [x] Discard validation message/value data, normalize field paths, strip iterable keys/indices, and
|
||||
emit only allowlisted reason codes with fixed messages.
|
||||
- [x] Route both `NoHandlerFoundException` and `NoResourceFoundException` through the same safe 404 envelope.
|
||||
- [x] Retain safe field/reason/expected-type/supported-method/media-type details and `Allow`.
|
||||
- [x] Run the operational/transport tests and confirm GREEN.
|
||||
|
||||
### Task 3: Sample Domain RED and Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioClientSafeErrorMessages.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java`
|
||||
- Modify: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java`
|
||||
|
||||
- [x] Add ID/title/reason sentinel tests and confirm RED (4 expected failures).
|
||||
- [x] Map every `PortfolioErrorCode` to fixed public text and use it from the advice.
|
||||
- [x] Confirm code/status/category remain unchanged and sentinels are absent.
|
||||
|
||||
### Task 4: Focused and Architecture Verification
|
||||
|
||||
- [x] Run `./gradlew :adapter:inbound:web:test --console=plain`.
|
||||
- [x] Run `./gradlew :sample-portfolio:test --console=plain`.
|
||||
- [x] Run focused Spotless/Checkstyle/SpotBugs tasks for both modules.
|
||||
- [x] Run `./gradlew verifyCleanArchitectureDependencies --console=plain`.
|
||||
- [x] Run `git diff --check` and request an independent read-only review.
|
||||
- [x] Apply the independent review findings and receive a no-Critical/no-Important code re-review;
|
||||
align this design/plan with the final validation and resource-404 contract.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Conditional Inbound Transport Boundary Implementation Plan
|
||||
|
||||
> **Execution:** Apply TDD independently per transport, then run exact no-skip qualification and an
|
||||
> independent read-only review before beginning P2 cleanup.
|
||||
|
||||
**Goal:** Make GraphQL, gRPC, and WebSocket opt-in status truthful, fail closed on unsafe activation,
|
||||
and release-blocked by real protocol evidence without adding them to the default runtime.
|
||||
|
||||
### Task 1: Runtime Membership and Opt-In Composition
|
||||
|
||||
**Files:** `src/config/architecture/modules.json`, `src/settings.gradle`, `src/build.gradle`,
|
||||
`src/app-bootstrap/build.gradle`, app-bootstrap conditional transport tests
|
||||
|
||||
- [ ] Add and fail-closed validate exact `runtime_memberships` for all 19 leaves.
|
||||
- [ ] Compare registry membership to both composition roots' direct production project edges.
|
||||
- [ ] Add an isolated conditional-transport test classpath containing all three opt-in leaves.
|
||||
- [ ] Prove the default graphs omit them and the explicit qualification graph contains them.
|
||||
|
||||
### Task 2: gRPC Safe Activation and Wire Errors
|
||||
|
||||
**Files:** `src/adapter/inbound/grpc/**`
|
||||
|
||||
- [ ] Add RED tests for disabled bean/listener absence and safe property defaults/validation.
|
||||
- [ ] Add real Netty feature RPC tests for auth success/failure and reflection disabled.
|
||||
- [ ] Add RED tests for throw, `onError(ApiErrorCarrier)`, and raw status sentinel paths.
|
||||
- [ ] Implement loopback-only explicit insecure mode, required feature authentication policy, and
|
||||
`ServerCall.close` sanitization.
|
||||
- [ ] Update dependencies, locks, README, and CLAUDE truthfully.
|
||||
|
||||
### Task 3: GraphQL Real HTTP Boundary
|
||||
|
||||
**Files:** `src/adapter/inbound/graphql/**`
|
||||
|
||||
- [ ] Add random-port HTTP tests for auth, CORS, GraphiQL/introspection policy, and health.
|
||||
- [ ] Add carrier/unknown exception sentinels and assert absence from the complete JSON response.
|
||||
- [ ] Change production resolver/config only where the RED wire contract proves necessary.
|
||||
- [ ] Update dependencies, locks, README, and CLAUDE truthfully.
|
||||
|
||||
### Task 4: WebSocket Safe Activation and Wire Boundary
|
||||
|
||||
**Files:** `src/adapter/inbound/websocket/**`
|
||||
|
||||
- [ ] Add RED settings/disabled-context tests and real STOMP origin/auth/subscription tests.
|
||||
- [ ] Add RED broker-send and ERROR-frame sentinel tests.
|
||||
- [ ] Add RED no-projection/no-broadcast plus safe projection broadcast tests.
|
||||
- [ ] Implement disabled default, validated settings, inbound authorization, safe error handler, and
|
||||
explicit primitive projection allowlist.
|
||||
- [ ] Update dependencies, locks, README, and CLAUDE truthfully.
|
||||
|
||||
### Task 5: Exact No-Skip Release Gate
|
||||
|
||||
**Files:** `src/build.gradle`, `.github/workflows/ci-quality-gates.yml`,
|
||||
`.github/ci-gate-matrix.yml`, `.github/scripts/verify-gate-matrix.sh`, wrapper manifest contract
|
||||
|
||||
- [ ] Register exact per-transport Test lanes with no-match/no-discovery/zero-skip enforcement.
|
||||
- [ ] Register the aggregate `conditionalTransportQualification` task.
|
||||
- [ ] Invoke it explicitly from the release-blocking quality job and add the gate-matrix record.
|
||||
- [ ] Add semantic tests that fail if any required lane or workflow invocation disappears.
|
||||
|
||||
### Task 6: Verification and Review
|
||||
|
||||
- [ ] Run each leaf `check`, exact qualification, app-bootstrap composition contract, dependency
|
||||
locks, env keys, architecture, public path, wrapper validation, and `git diff --check`.
|
||||
- [ ] Run full `test`/`check` in proportion to the cross-cutting registry/build changes.
|
||||
- [ ] Request independent read-only review; resolve all Critical/Important findings.
|
||||
- [ ] Capture the batch in the LLM Wiki before final completion reporting.
|
||||
|
||||
### Explicit P2 Deferral
|
||||
|
||||
- GraphQL feature schema, field auth, cost/depth, persisted queries, DataLoader, subscriptions.
|
||||
- gRPC TLS/mTLS, external bind, proto compatibility, deadlines, streaming/backpressure.
|
||||
- WebSocket broker relay, multi-node delivery, resume/replay, backpressure, versioned feature catalog.
|
||||
- Transport dashboards, SLO alerts, and provider/ingress qualification.
|
||||
# Implementation status
|
||||
|
||||
- Completed on 2026-08-02.
|
||||
- Verified by `conditionalTransportQualification`: GraphQL 8, gRPC 15, WebSocket 5,
|
||||
composition 1; skipped 0.
|
||||
- Verified by the real CI gate-matrix validator and focused bypass regression tests.
|
||||
- Independent review result: READY, Critical 0 / Important 0 / Minor 0.
|
||||
@@ -0,0 +1,268 @@
|
||||
# P2 Verification Governance Refactoring Plan
|
||||
|
||||
## Batch 1 — strict owner-local qualification
|
||||
|
||||
- [x] Add TestKit RED cases for empty source sets, missing FQCNs, disabled-only tests, and one valid
|
||||
test.
|
||||
- [x] Add the shared strict qualification convention.
|
||||
- [x] Move conditional transport and Messaging task registration from root to owner projects.
|
||||
- [x] Adopt the convention for object-storage, Poster migration, and composition qualifications.
|
||||
- [x] Keep root tasks as absolute-path aggregators and verify all evidence XML.
|
||||
- [x] Run focused TestKit, every migrated qualification lane, locks, and independent review.
|
||||
|
||||
Evidence: eight TestKit cases passed fresh; conditional transport ran 8/15/5/1 tests and Messaging
|
||||
ran 15/6/4/29/28 tests with zero skips. All dependency locks passed. Object-storage and Poster
|
||||
required-class preflights passed; protected AWS and Docker-backed full lanes remain environment-
|
||||
qualified. Independent review closed with no remaining Critical, Important, or Minor findings.
|
||||
|
||||
## Batch 2 — tracked contract resources hard-fail
|
||||
|
||||
- [x] Add RED tests proving absent tracked files/directories fail instead of aborting.
|
||||
- [x] Add `RepositoryContractResources` and inject the canonical repository root.
|
||||
- [x] Replace stale tracked-resource assumptions in the contract corpus.
|
||||
- [x] Preserve assumptions only for genuinely optional external infrastructure.
|
||||
- [x] Run focused representative contracts, scan for stale skip language, and run app-bootstrap
|
||||
`check`.
|
||||
|
||||
Evidence (2026-08-02): the fail-closed repository resolver is covered by 11 boundary tests;
|
||||
Runbook coverage and lock-classification contracts passed with zero skips. Independent review found
|
||||
and closed both direct-link and directory-enumeration symlink escapes. A fresh
|
||||
`./gradlew :app-bootstrap:check --no-daemon --console=plain` passed (77 tasks; 18 executed, 59
|
||||
up-to-date), and the final Batch 2 review reported zero Critical, Important, or Minor findings.
|
||||
|
||||
## Batch 3 — real gate-matrix mutation tests
|
||||
|
||||
- [x] Add temporary-fixture tests that execute the shell validator itself.
|
||||
- [x] Make the validator accept a repository-root argument without changing default CI behavior.
|
||||
- [x] Delete the duplicated Java command parser.
|
||||
- [x] Cover deceptive names, suppression flags, missing/duplicate gates, and missing task wiring.
|
||||
- [x] Run the focused contract, real repository validator, and wrapper verifier.
|
||||
|
||||
Evidence (2026-08-02): the initial focused RED compiled and reported seven failing contracts against
|
||||
the old validator. Independent review found arbitrary project-qualified task matching, shorthand
|
||||
step parsing, generic `name:` registration, relocated-script guard evidence, unsafe custom refs,
|
||||
missing `check` wiring evidence, and process-tree cleanup gaps; each was closed with a regression
|
||||
test or bounded cleanup. A final regex-boundary audit also closed custom-task and plugin-ref ERE
|
||||
injection with literal-safe grammars and fixed-string plugin lookup. The final focused contract
|
||||
passed all 16 tests using bounded
|
||||
`ProcessBuilder` execution of the real shell script. `bash .github/scripts/verify-gate-matrix.sh`
|
||||
passed with 27 gates (26 verified and one explicitly delegated),
|
||||
`bash .github/scripts/verify-gradle-wrapper.sh .` passed, `bash -n` and
|
||||
`:app-bootstrap:spotlessJavaCheck` passed, and `git diff --check` reported no whitespace errors.
|
||||
|
||||
## Batch 4 — Redis manifest JSON Schema conformance
|
||||
|
||||
- [x] Add invalid-manifest RED fixtures for bounds, patterns, required fields, and extra fields.
|
||||
- [x] Validate the canonical schema and all manifests with Draft 2020-12 semantics.
|
||||
- [x] Retain Java-catalog equality checks for cross-resource invariants.
|
||||
- [x] Run the focused schema test, cache-redis `check`, and dependency-lock verification.
|
||||
|
||||
Evidence (2026-08-02): the initial focused RED compile failed on the deliberately missing
|
||||
`RedisProgramManifestSchemaValidator` (six `cannot find symbol` errors). NetworkNT 3.0.2 now
|
||||
validates the canonical schema against its bundled Draft 2020-12 meta-schema and validates the
|
||||
exact six closed manifests under strict parsing/configuration. Mutation coverage exercises
|
||||
additional properties, type, required, enum, minimum/maximum, pattern, duplicate JSON keys, and
|
||||
an independent cross-resource duplicate-program-id Java invariant. The first GREEN attempt exposed
|
||||
that the canonical ACL pattern rejected the existing `SCRIPT|LOAD` command form; the pattern was
|
||||
narrowly relaxed before independent review identified that it also admitted dangerous commands.
|
||||
A second RED run failed exactly two tests because the schema had no exact allowlist and accepted
|
||||
`FLUSHALL`, `CONFIG|SET`, and `MODULE|LOAD`. The six canonical manifests contain 265 ACL command
|
||||
occurrences and exactly 37 unique commands; `aclCommands.items` now uses that exact enum so adding
|
||||
a command requires an explicit schema change. Review coverage also rejects a trailing manifest
|
||||
JSON token and a duplicate schema key on the compile path, and pins invalid meta-schema diagnostics
|
||||
to `/type:type`. Final verification passed:
|
||||
`./gradlew :adapter:outbound:cache-redis:test --tests '*RedisProgramManifestContractTest' --console=plain`
|
||||
(12 tests), `./gradlew :adapter:outbound:cache-redis:test
|
||||
:adapter:outbound:cache-redis:spotlessJavaCheck --console=plain`,
|
||||
`./gradlew :adapter:outbound:cache-redis:verifyDependencyLocks
|
||||
:adapter:outbound:cache-redis:spotlessCheck --console=plain`, and
|
||||
`./gradlew :adapter:outbound:cache-redis:check --console=plain`. The owner lock gained only
|
||||
`com.networknt:json-schema-validator:3.0.2` and `com.ethlo.time:itu:1.14.0`; no
|
||||
`tools.jackson.dataformat:jackson-dataformat-yaml` entry is present. `git diff --check` passed.
|
||||
The configured owner `check` remained successful while its SpotBugs test report retained one
|
||||
pre-existing `DMI_RANDOM_USED_ONLY_ONCE` finding in `RedisPrimitiveRuntimeServiceTest`; the new
|
||||
schema validator and contract test introduced no SpotBugs finding.
|
||||
|
||||
## Batch 5 — registry and runbook governance
|
||||
|
||||
- [x] Enforce an exact catalog for every tracked registry, including object-storage readiness.
|
||||
- [ ] Resolve every stable `required_test` ID exactly once and reject dangling mappings.
|
||||
- [ ] Replace the Java runbook stub allowlist with owned, issue-linked, expiring debt data.
|
||||
- [ ] Clarify tracked registry ownership and private-wiki provenance.
|
||||
- [x] Run schema, object-storage readiness, runbook, app-bootstrap, and root checks. The checks
|
||||
exercise the mechanically enforceable catalog/containment rules; the three semantic migrations
|
||||
above remain explicitly blocked on project-owner evidence.
|
||||
|
||||
### Batch 5-A evidence — exact tracked registry catalog (2026-08-02)
|
||||
|
||||
The owner catalog now enumerates exactly eight regular, non-symlink direct children: seven
|
||||
universal contract registries plus the specialized object-storage readiness registry. The initial
|
||||
focused RED failed compilation on the deliberately absent `RegistryGovernanceCatalog` (13 symbol
|
||||
errors). A second exact-version mutation RED proved that numeric coercion admitted
|
||||
`schema_version: 1.5`; the implementation now requires the integer value `1`. Strict SnakeYAML
|
||||
safe construction disables duplicate keys and aliases, enforces exact root keys, a non-empty list
|
||||
of map rows, non-blank unique identities, the existing universal row policy, and the specialized
|
||||
owner delegation/provenance policy. Missing, unknown, non-regular, symlinked, malformed, duplicate,
|
||||
false-provenance, block-scalar spoofing, reordered-header, and fabricated-branch-header fixtures
|
||||
fail closed.
|
||||
|
||||
Gradle declares `docs/registries` as a relative-path-sensitive `:app-bootstrap:test` directory
|
||||
input. The object-storage owner declares its canonical readiness YAML as a relative-path-sensitive
|
||||
file input and passes its absolute path through `objectstorage.readiness.registry`; its leaf test no
|
||||
longer searches parent directories. The tracked specialized registry header is exactly four
|
||||
ordered leading comment lines containing only the factual repository and semantic owner Gradle
|
||||
paths and test FQCNs.
|
||||
|
||||
Fresh verification passed:
|
||||
|
||||
- `./gradlew :app-bootstrap:test --tests
|
||||
dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest --console=plain`
|
||||
- `./gradlew :adapter:outbound:objectstorage:test --tests
|
||||
dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
|
||||
--console=plain`
|
||||
- `./gradlew :app-bootstrap:test --console=plain` (38 tasks; 2 executed)
|
||||
- `./gradlew :app-bootstrap:check --console=plain` (77 tasks; 21 executed)
|
||||
- `./gradlew :app-bootstrap:spotlessJavaCheck
|
||||
:adapter:outbound:objectstorage:spotlessJavaCheck --console=plain`
|
||||
- `git diff --check`, an exact direct-child regular-file audit, the owner-path `jq` audit, and
|
||||
`yq eval 'true' docs/registries/*.yaml` (eight parsed documents)
|
||||
|
||||
### Batch 5-C partial containment evidence — legacy runbook stub debt (2026-08-02)
|
||||
|
||||
This is bounded containment, not completion of the owned, issue-linked, expiring debt-ledger item
|
||||
above. The Java set is now named `LEGACY_STUB_DEBT`, contains exactly the 43 current
|
||||
`status: stub` runbooks, and is checked bidirectionally against canonical tracked runbook files.
|
||||
The stale `migration-failed.md` entry was removed because that runbook is already active. Active,
|
||||
missing, template, and newly introduced stub drift now fail the same exact-set contract. Messages
|
||||
and the runbook template forbid adding new legacy allowlist entries and direct maintainers to
|
||||
complete the runbook or adopt the future governed ledger.
|
||||
|
||||
The focused RED failed only because `migration-failed.md` was an unexpected legacy-debt element.
|
||||
After the containment change, the focused Runbook contract passed with 6 tests, zero failures, and
|
||||
zero skips. Fresh verification also passed `:app-bootstrap:spotlessJavaCheck` and
|
||||
`:app-bootstrap:check` (77 tasks; 18 executed, 59 up-to-date). Owner, issue, start/sunset,
|
||||
expiry enforcement, and the private-wiki provenance migration remain deliberately incomplete and
|
||||
the corresponding Batch 5 checkboxes remain open.
|
||||
|
||||
### Batch 5-B/C unresolved semantic migrations audit (2026-08-02)
|
||||
|
||||
These items are intentionally not marked complete. The seven universal registries contain 324
|
||||
non-reference `required_test` occurrences and 216 unique IDs. There is no tracked selector
|
||||
catalog, no Gradle declaration containing those IDs, and no ID that can currently be proven to
|
||||
resolve to one exact module/task/class/method selector. Exact Java test-source literals cover only
|
||||
17 IDs (45 occurrences, 40 in comments/Javadocs); 199 IDs have no exact source literal. Creating
|
||||
216 selectors from namespaces or historical branch labels would manufacture execution evidence,
|
||||
so the exact-linkage gate requires semantic owner confirmation or new tests before it can be
|
||||
enabled.
|
||||
|
||||
The runbook corpus contains 43 stub documents, all with response owner `oncall` but no accountable
|
||||
debt owner, real issue, approved expiry, or bounded debt window. The seven legacy registries contain
|
||||
30 distinct `owner_branch` labels, none resolving to a current local/remote Git ref, while their
|
||||
private-wiki paths are absent from a fresh clone. The repository files are now protected as the
|
||||
tracked artifacts, but current owner IDs, historical-label migration, CODEOWNERS identities,
|
||||
runbook expiry dates, the `INTERNAL_ERROR` reverse-link decision, and the four umbrella-runbook
|
||||
retention decisions require real project-owner input. Placeholder owners, issues, selectors, and
|
||||
sunsets were not added to make the checks pass.
|
||||
|
||||
## Batch 6 — bounded P2 cleanup
|
||||
|
||||
- [x] Extend link-check triggers and scan scope to module README/CLAUDE documents.
|
||||
- [x] Make Poster migration gate labels version-neutral while preserving externally stable job IDs.
|
||||
- [x] Replace fixed HTTP timeout sleeps with deterministic latch-controlled handlers.
|
||||
- [x] Separate sample-off compile evidence from its minimal runtime proof if exact required tests can
|
||||
be established without weakening coverage.
|
||||
- [x] Run focused docs, CI, HTTP client, sample-off, and wrapper checks.
|
||||
|
||||
Batch 6 link/Poster evidence: test-first changes made the two focused app-bootstrap contracts fail
|
||||
only for the absent module documentation scope and the legacy Poster V7 internal gate ID. The same
|
||||
contracts then passed with exact pull/push/lychee scope, all 27 gate IDs, and the stable external
|
||||
`poster-image-v7-migration` workflow job plus `posterImageMigrationTest` task mapping. The full
|
||||
`DeveloperExperienceContractTest` and `ConditionalTransportQualificationContractTest` classes
|
||||
passed, `posterImageMigrationTest` produced 4 tests with zero skips, and both the 27-entry gate
|
||||
validator and Gradle wrapper verifier passed. The complete sorted six-workflow SHA-256 lock was
|
||||
refreshed after review; app-bootstrap Java and sample-portfolio Spotless checks also passed. An
|
||||
independent Batch 6 link/Poster read-only review found no Critical, Important, or Minor issues.
|
||||
|
||||
Batch 6 HTTP evidence: the focused synchronization contract first failed on exactly five fixed
|
||||
sleeps across `OutboundHttpClientTest` (one), `OutboundHttpClientDeadlineTest` (one), and
|
||||
`OutboundCallExecutorTest` (three). The HTTP handlers now signal `requestStarted`, await a bounded
|
||||
`releaseResponse` latch, and are released in the caller's `finally` after the timeout result and
|
||||
classification assertions. Executor workers now block on a bounded latch interruption point, with
|
||||
the existing started/interrupted evidence and caller cleanup preserved. The four focused classes
|
||||
passed 25 tests with zero failures, errors, or skips. A 3-second read-timeout mutation failed when
|
||||
the handler's 1-second HTTP 204 fallback completed successfully, proving that the test cannot pass
|
||||
via the separate 5-second logical deadline. The full owner `test` passed, and
|
||||
`:adapter:outbound:httpclient:check` passed 29 tasks (16 executed, 13 up-to-date), including
|
||||
Spotless, Checkstyle, SpotBugs, architecture dependencies, and environment-key verification. No
|
||||
production source changed. Independent re-review found no remaining Critical, Important, or Minor
|
||||
issues and found no cleanup leak or deadlock race.
|
||||
|
||||
Batch 6 sample-off evidence: the focused build contract first failed because the dedicated source
|
||||
directory, compile lifecycle task, strict registration, and required FQCN did not exist. The
|
||||
`sampleOffTest` source set now compiles all 204 ordinary test sources plus the dedicated contract
|
||||
without `sample-portfolio`, while `sampleOffCompile` exposes that complete compile proof separately.
|
||||
The externally stable `sampleOffTest` task is registered through the shared strict qualification
|
||||
convention and executes only `SampleOffClasspathContractTest`; fresh XML reported exactly 1 test,
|
||||
0 skipped, 0 failures, and 0 errors. The existing eight strict-convention functional contracts
|
||||
passed, including missing-class, no-discovery, skip, and stale-evidence fail-closed cases. The
|
||||
focused build contract, `sampleOffCompile`, gate-matrix validator, wrapper verifier, dependency-lock
|
||||
verification, Spotless, and the full `:app-bootstrap:check` also passed; the full check completed 78
|
||||
tasks (23 executed, 55 up-to-date). This is focused/owner evidence; the repository-wide Batch 6
|
||||
aggregate is recorded below.
|
||||
|
||||
Batch 6 repository evidence (2026-08-02): the real gate-matrix validator passed all 27 entries
|
||||
(26 locally verified and the protected AWS lane explicitly delegated-pending), the Gradle-wrapper
|
||||
contract passed, `bash -n .github/scripts/verify-gate-matrix.sh` passed, all eight tracked registry
|
||||
YAML documents parsed, the Redis Draft 2020-12 schema parsed as JSON, and `git diff --check`
|
||||
reported no whitespace errors. The first repository `check` exposed a 503 in the first
|
||||
`JwtJwksSecurityFilterIntegrationTest` request while static-analysis workers were running. The
|
||||
single test passed in isolation, identifying a test-fixture scheduling race rather than a JWT
|
||||
classification mismatch. The embedded OIDC server now owns a dedicated single daemon executor and
|
||||
shuts it down in `close()`; the full eight-test security-boundary lane plus Checkstyle and Spotless
|
||||
passed, and a fresh repository `check` subsequently passed with the same boundary lane included.
|
||||
|
||||
## Final verification and capture
|
||||
|
||||
- [x] Run full Gradle tests/checks and all repository validators.
|
||||
- [x] Request an independent P2 code review, resolve actionable findings, and record semantic
|
||||
blockers separately.
|
||||
- [x] Update the LLM Wiki branch note and any honest derived raw documents.
|
||||
|
||||
Fresh aggregate evidence (2026-08-02):
|
||||
|
||||
- `./gradlew test --no-daemon --console=plain` — successful in 4m 24s (86 tasks).
|
||||
- `./gradlew check --no-daemon --console=plain` — first run failed only on the OIDC test-fixture
|
||||
race above; after the bounded fixture correction, successful in 4m 35s (260 tasks).
|
||||
- Final post-review `./gradlew check --no-daemon --console=plain` — successful in 10m 33s
|
||||
(260 tasks; 76 executed, 184 up-to-date). It regenerated the SampleRemoval result after the
|
||||
source edit: 5 tests, zero skipped/failures/errors.
|
||||
- `./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership
|
||||
verifyDependencyLocks verifyPublicPathSnapshot verifyEnvKeys --no-daemon --console=plain` —
|
||||
successful (23 tasks); all 19 leaf locks passed and two runtime compositions matched the registry.
|
||||
- Real gate-matrix, wrapper, shell syntax, Redis JSON, registry YAML, and diff validators — all
|
||||
successful; the protected AWS qualification remains explicitly delegated to its environment.
|
||||
- Final `verifyDependencyLocks` rerun — successful in 24s with all 19 leaf tasks executed. The
|
||||
tracked-file assumption audit now reports only four Docker/Testcontainers integration
|
||||
assumptions; no registry or repository-contract assumption remains.
|
||||
|
||||
LLM Wiki capture evidence (2026-08-02): `raw/branch-notes/main.md` records the integrated P1/P2
|
||||
implementation, decisions, validation commands, failures, evidence grades, and unresolved semantic
|
||||
migrations. It links bidirectionally to one resolved error note, one interview-prep note, and one
|
||||
blog-topic note. The vault's targeted structure lint passed all three derived documents. The branch
|
||||
note passed its content, frontmatter, required-section, and wikilink checks but retained one explicit
|
||||
`NAMING_VIOLATION`: repository policy requires `<branch-name>.md` (`main.md`) while the vault naming
|
||||
rule permits only `feature|fix|chore|experiment-` branch-note prefixes. Neither policy was silently
|
||||
weakened; the exact conflict is the recorded capture-validation blocker.
|
||||
|
||||
Independent aggregate review evidence (2026-08-02): the first pass reported zero critical,
|
||||
three important, and two minor findings. Wiki capture closed the capture-pending finding; the two
|
||||
remaining important items were reclassified as the three project-semantic blockers already kept
|
||||
open in Batch 5. The two minor code findings were corrected with an exact test-fixture-only
|
||||
GraphQL SpotBugs exclusion and registry-derived scanning of all 18 production leaves in
|
||||
`SampleRemovalSmokeContractTest`. A follow-up audit also found and removed the last tracked-file
|
||||
assumption/upward-directory search in `PortfolioErrorCodeRegistryMappingTest`, replacing it with a
|
||||
canonical repository-root property, relative Gradle input, and missing-root/symlink-escape
|
||||
fail-closed checks. The re-review found no new code defect; its only completion-evidence concern
|
||||
was a stale SampleRemoval XML, addressed by the final repository `check` after these corrections.
|
||||
The reviewer retained only the Wiki naming-policy disclosure and this Batch 5 checkbox wording as
|
||||
minor documentation findings; both are now explicit here and in the branch note.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Redis Session HTTP Boundary Implementation Plan
|
||||
|
||||
> **Execution:** Follow test-driven development and request an independent read-only review before
|
||||
> advancing to the remaining P1 work.
|
||||
|
||||
**Goal:** Prove browser-session security persists and fails closed across the real Spring Session ↔
|
||||
Redis composition, without silent skips.
|
||||
|
||||
**Architecture:** The app-bootstrap composition test reuses its existing Redis test source set and
|
||||
dependencies. It assembles inbound-web and cache-redis without adding a forbidden leaf-to-leaf edge.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 4.0.0, Spring Security 7, Spring Session 4, Testcontainers 2,
|
||||
Redis 7.4 digest-pinned image, MockMvc, Gradle 9.
|
||||
|
||||
### Task 1: Explicit Docker No-Skip Gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app-bootstrap/build.gradle`
|
||||
|
||||
- [x] Exclude `redis-session-http` from ordinary `redisCompositionTest`.
|
||||
- [x] Register `redisSessionHttpIntegrationTest` over the same source output/classpath with tag
|
||||
inclusion, no-discovery failure, no-skip root-suite guard, UTC, rerun, and image-registry property.
|
||||
- [x] Keep the Docker task outside ordinary `check`; reuse Spring Session 4.0.0 and lock only the
|
||||
added `redisCompositionTestCompileClasspath` configuration.
|
||||
|
||||
### Task 2: Real Session HTTP RED Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionHttpBoundaryIntegrationTest.java`
|
||||
|
||||
- [x] Load and validate the approved digest-pinned Redis image; explicitly start the container.
|
||||
- [x] Generate ephemeral TLS/ACL/password/HMAC material and assemble canonical SESSION-role
|
||||
configuration with full hostname verification and explicit trust.
|
||||
- [x] Cross CSRF, login, Spring Session filter, primitive snapshot, and hardened cookie creation.
|
||||
- [x] Close context A and prove context B restores the authenticated principal from Redis.
|
||||
- [x] Prove logout/tombstone rejects the old cookie and a stale repository save.
|
||||
- [x] Stop Redis during lookup and prove fail-closed controller behavior with fixed diagnostics.
|
||||
- [x] Record and resolve RED composition mismatches: response-commit session creation and framework
|
||||
request-cache serialization.
|
||||
|
||||
### Task 3: CI Release Gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/ci-quality-gates.yml`
|
||||
|
||||
- [x] Add `:app-bootstrap:redisSessionHttpIntegrationTest` to the existing `redis-standalone` job.
|
||||
- [x] Keep the existing required gate identity and matrix dependency unchanged.
|
||||
|
||||
### Task 4: Verification and Review
|
||||
|
||||
- [x] Run the explicit HTTP task and existing app-bootstrap Redis composition task.
|
||||
- [x] Run the selected cache-redis session capability lane, dependency locks, env keys, architecture,
|
||||
public-path snapshot, static analysis, and `git diff --check`.
|
||||
- [x] Request an independent read-only review and resolve all Critical/Important findings.
|
||||
|
||||
### Verification Evidence
|
||||
|
||||
- `:app-bootstrap:redisSessionHttpIntegrationTest`: 1 test, 0 skipped, GREEN.
|
||||
- `:adapter:outbound:cache-redis:redisSessionCapabilityTest`: GREEN with sanitized evidence.
|
||||
- `:adapter:inbound:web:check`: unit/contract/static analysis and 13 no-skip JWT/CORS boundary
|
||||
tests GREEN.
|
||||
- `:app-bootstrap:check :app-bootstrap:redisCompositionTest`: 640 bootstrap tests (6 pre-existing
|
||||
conditional Docker skips in the ordinary suite, not used as this gate's evidence), TestKit
|
||||
contracts, 14 Redis composition tests, Checkstyle, SpotBugs, and Spotless GREEN.
|
||||
- `verifyDependencyLocks verifyEnvKeys verifyCleanArchitectureDependencies
|
||||
verifyPublicPathSnapshot`: GREEN for all 19 registered leaves.
|
||||
- Review RED: final context reconciliation could retain the authentication saved at response commit;
|
||||
host TLS/ACL material permissions were too broad; the CI task lacked a semantic workflow assertion.
|
||||
- Review fixes: authoritative final empty/replacement context tests went RED then GREEN, async start
|
||||
defers commit-hook persistence, host material is `0700`/`0600` and copied selectively into the
|
||||
fixture, and the blocking Redis job is now asserted directly.
|
||||
- Independent re-review: Critical 0, Important 0, Minor 0; batch READY.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Verification Purity Refactoring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make stale-JAR and public-path verification strictly read-only while preserving explicit cleanup/update workflows.
|
||||
|
||||
**Architecture:** Extract only these two root Gradle concerns into applied scripts so the production tasks can be exercised by isolated Gradle TestKit fixtures. Verification tasks only observe and fail; `clean*` and `update*` tasks are the sole writers.
|
||||
|
||||
**Tech Stack:** Java 21, Gradle 9.0.0 Groovy DSL, Gradle TestKit, JUnit 5, AssertJ.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all existing P0 changes in the dirty worktree.
|
||||
- Preserve the 19-leaf registry and every production project dependency edge.
|
||||
- Normal archive tasks and every `verify*` task must be read-only.
|
||||
- `updatePublicPathSnapshot` requires `-PapprovePublicPathChange`.
|
||||
- Agents do not stage, commit, amend, or push.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Functional RED Contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java`
|
||||
- Modify: `src/app-bootstrap/build.gradle`
|
||||
- Modify: `src/app-bootstrap/gradle.lockfile`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: production scripts at `src/gradle/archive-hygiene.gradle` and `src/gradle/public-path-snapshot.gradle`.
|
||||
- Produces: functional tests that execute real Gradle tasks and assert filesystem side effects.
|
||||
|
||||
- [x] Add an isolated `functionalTest` source set/task and its `functionalTestImplementation gradleTestKit()` dependency so Gradle's SLF4J provider cannot pollute ordinary tests.
|
||||
- [x] Add a nested temporary archive fixture with root + `family:module` projects. Apply the production archive script, pre-create a stale traceable JAR and a nonmatching JAR, run `:family:module:jar`, `verifyNoStaleTraceableJars`, and `cleanStaleTraceableJars`, and assert exact preservation/deletion plus the full task-path diagnostic.
|
||||
- [x] Add a temporary public-path fixture. Apply the production public-path script and assert missing/drifted snapshots are not written, the verifier rejects `-PapprovePublicPathChange`, and only the approved updater writes canonical content.
|
||||
- [x] Confirm the contracts RED before the two production scripts exist. The first RED run used the ordinary test source set; after it exposed Gradle TestKit's SLF4J provider collision, move the contract and TestKit dependency to isolated `functionalTest` configurations and add their strict lock state.
|
||||
|
||||
### Task 2: Separate Archive Verification from Cleanup
|
||||
|
||||
**Files:**
|
||||
- Create: `src/gradle/archive-hygiene.gradle`
|
||||
- Modify: `src/build.gradle`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: root tasks `verifyNoStaleTraceableJars` and `cleanStaleTraceableJars` with no dependency between them.
|
||||
|
||||
- [x] Move traceable archive matching/discovery and both root tasks into the applied script.
|
||||
- [x] Remove the stale-deleting `doFirst` from every `Jar` task while retaining manifest metadata.
|
||||
- [x] Apply the script before leaf `check` dependencies are configured; task actions discover leaf JAR tasks at execution time.
|
||||
- [x] Explicitly declare both archive tasks configuration-cache incompatible because their actions inspect subproject task models.
|
||||
- [x] Run the focused functional test and confirm archive cases are GREEN.
|
||||
|
||||
### Task 3: Separate Public-Path Verification from Update
|
||||
|
||||
**Files:**
|
||||
- Create: `src/gradle/public-path-snapshot.gradle`
|
||||
- Modify: `src/build.gradle`
|
||||
- Modify: `src/README.md`
|
||||
- Modify: `docs/security/public-paths-snapshot.txt`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: read-only `verifyPublicPathSnapshot` and explicitly mutating `updatePublicPathSnapshot`.
|
||||
|
||||
- [x] Centralize canonical snapshot rendering in the script.
|
||||
- [x] Make verification fail on missing env, missing snapshot, drift, and use of the approval property without any writes.
|
||||
- [x] Make update require `-PapprovePublicPathChange`, create the parent directory, and write canonical content.
|
||||
- [x] Replace documentation and snapshot instructions with `updatePublicPathSnapshot -PapprovePublicPathChange`.
|
||||
- [x] Run the focused functional test and confirm all public-path cases are GREEN.
|
||||
|
||||
### Task 4: Focused and Architecture Verification
|
||||
|
||||
**Files:** none beyond Tasks 1-3.
|
||||
|
||||
- [x] Run `./gradlew :app-bootstrap:functionalTest --tests '*BuildVerificationPurityContractTest' --console=plain`.
|
||||
- [x] Run `./gradlew :app-bootstrap:test --console=plain`; 640 ordinary tests pass after TestKit isolation (6 skipped), alongside the 9 functional contracts.
|
||||
- [x] Run `./gradlew :app-bootstrap:verifyDependencyLocks --console=plain`.
|
||||
- [x] Run `./gradlew :app-bootstrap:spotlessJavaCheck :app-bootstrap:checkstyleFunctionalTest :app-bootstrap:spotbugsFunctionalTest --console=plain`.
|
||||
- [x] Run `./gradlew verifyNoStaleTraceableJars verifyPublicPathSnapshot --console=plain` and confirm both are read-only and pass on the current baseline.
|
||||
- [x] Run `./gradlew verifyCleanArchitectureDependencies --console=plain`.
|
||||
- [x] Run `git diff --check` and record `git status --short` without staging or committing.
|
||||
@@ -0,0 +1,387 @@
|
||||
# Warning-Zero Build Refactoring Implementation Plan
|
||||
|
||||
> **For Codex:** REQUIRED SUB-SKILLS: use `superpowers:subagent-driven-development` for the
|
||||
> independent owner-leaf batches, `superpowers:test-driven-development` for behavior changes,
|
||||
> `superpowers:systematic-debugging` for any failure, and
|
||||
> `superpowers:verification-before-completion` before reporting success.
|
||||
|
||||
**Goal:** Remove the audited compiler/static-analysis/test-output warning debt, preserve the approved
|
||||
legacy compatibility boundaries, and make the blocking build fail on any future warning.
|
||||
|
||||
**Architecture:** Fix behavior in the owning leaf, preserve identity/framework/compatibility seams
|
||||
with the narrowest justified suppressions, migrate deprecated provider APIs in their outbound leaf,
|
||||
then enable root Gradle/CI gates only after all focused tasks are clean. No dependency edge or runtime
|
||||
membership changes are permitted. The 19-leaf registry remains the dependency SSOT.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle multi-project build, JUnit 5, AssertJ, Mockito,
|
||||
Error Prone, Checkstyle, SpotBugs, Jackson 3.0.2, Lettuce 6.8.1, AWS SDK v2, Testcontainers 2.
|
||||
|
||||
**Approved design:**
|
||||
`docs/superpowers/specs/2026-08-02-warning-zero-build-design.md`
|
||||
|
||||
**Repository constraints:** The worktree already contains user/P0/P1/P2 changes. Preserve them,
|
||||
never reset or rewrite unrelated files, and do not stage, commit, amend, or push. Agent tasks must
|
||||
edit only their assigned files and report overlaps before proceeding.
|
||||
|
||||
## Task 1: Freeze warning evidence and add behavior regressions
|
||||
|
||||
**Owner leaves:** `adapter-inbound-web`, `adapter-outbound-notification`, `sample-portfolio`,
|
||||
`app-bootstrap`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Add: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverterTest.java`
|
||||
- Modify: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ETagsTest.java`
|
||||
- Modify: `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java`
|
||||
- Modify: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java`
|
||||
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncGracefulShutdownBehaviorTest.java`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Add Turkish-default-locale regressions for JWT role uppercasing, notification route-key
|
||||
lowercasing, and repository ACL lowercasing. Snapshot `Locale.getDefault()`, set
|
||||
`Locale.forLanguageTag("tr-TR")`, and restore it in `finally`.
|
||||
2. Add RED ETag cases for `"opaque,tag"`, weak `W/"opaque,tag"` inside a mixed list, malformed
|
||||
unclosed quotes, wildcard, blank, stale, and ordinary multiple values.
|
||||
3. Add a RED async case proving an exception raised in the submitted action reaches the test through
|
||||
`Future.get()`.
|
||||
4. Run the exact focused tests. Confirm the new locale/ETag cases fail for the intended reason; the
|
||||
async change uses the existing `FutureReturnValueIgnored` compile diagnostic as its RED contract:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:inbound:web:test --tests '*JwtToAuthenticatedPrincipalConverterTest' --tests '*ETag*' --console=plain
|
||||
./gradlew :adapter:outbound:notification:test --tests '*RoutingNotifier*' --console=plain
|
||||
./gradlew :sample-portfolio:test --tests '*RepoStatsAclMapper*' --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*AsyncGracefulShutdownBehaviorTest' --console=plain
|
||||
```
|
||||
|
||||
5. Do not change production code in this task; retain the behavior-test failures and compile warning
|
||||
as the TDD/static-analysis baseline.
|
||||
|
||||
## Task 2: Correct locale, ETag, async, cleanup, and host-default behavior
|
||||
|
||||
**Owner leaves:** `adapter-inbound-web`, `adapter-outbound-notification`, `sample-portfolio`,
|
||||
`app-bootstrap`, `application-core`, `shared-contract`, `adapter-outbound-fileserver`,
|
||||
`adapter-outbound-httpclient`, `adapter-outbound-identifier`
|
||||
|
||||
**Production files:**
|
||||
|
||||
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java`
|
||||
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java`
|
||||
- Modify: `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java`
|
||||
|
||||
**Test/mechanical files:**
|
||||
|
||||
- Modify the nine audited implicit-charset sites in `CursorCodecTest`,
|
||||
`RedisTrustMaterialProviderTest`, `OutboundHttpClientTest`,
|
||||
`HmacUserPrincipalPseudonymizerTest`, `StreamingResponseBodyAllowedFixture`, and
|
||||
`IdempotencyExecutorTest`.
|
||||
- Modify the remaining audited test-only locale sites in `JwtDecoderConfigTest`,
|
||||
`OutboundHttpClientTest`, `WorkLogReservedIntegrationEventMapperJsonTest`, `WorkLogIdTest`, and
|
||||
`TraceParentTest`.
|
||||
- Modify: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java`
|
||||
- Modify the four outbox cleanup classes under
|
||||
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/`.
|
||||
- Modify: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Use `Locale.ROOT` at the three production identifier sites and at audited test comparisons.
|
||||
2. Replace `ETags` delimiter splitting with a quote-aware scanner. Split only on commas outside
|
||||
quoted opaque tags; malformed quoting yields no match. Keep wildcard and weak-tag semantics.
|
||||
3. Retain and observe the async `Future<?>`; unwrap `ExecutionException` only as required by the
|
||||
test's existing assertion contract.
|
||||
4. Replace empty cleanup catches with propagation or `IllegalStateException`/`UncheckedIOException`
|
||||
preserving the original cause.
|
||||
5. Replace implicit charset calls with `StandardCharsets.UTF_8`; replace `LocalDate.now()` test data
|
||||
with the fixed intended date or an explicit UTC clock.
|
||||
6. Convert byte-identical readability literals to text blocks and verify the exact expected strings.
|
||||
7. Run the focused tests from Task 1 and the affected owner test suites:
|
||||
|
||||
```bash
|
||||
./gradlew :application-core:test :shared-contract:test :adapter:inbound:web:test \
|
||||
:adapter:outbound:notification:test :adapter:outbound:fileserver:test \
|
||||
:adapter:outbound:httpclient:test :adapter:outbound:identifier:test \
|
||||
:sample-portfolio:test :app-bootstrap:test --console=plain
|
||||
```
|
||||
|
||||
## Task 3: Preserve Redis invariants and migrate Lettuce calls
|
||||
|
||||
**Owner leaf:** `adapter-outbound-cache-redis`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocation.java`
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntime.java`
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSession.java`
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStore.java`
|
||||
- Add: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocationTest.java`
|
||||
- Add: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStoreTest.java`
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java`
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepositoryTest.java`
|
||||
- Modify: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRuntimeServiceTest.java`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Add characterization regressions proving a value-equal descriptor from a different catalog is
|
||||
rejected and the four session array records copy constructor inputs and accessor outputs. These
|
||||
should pass before implementation because they justify preserving the invariants; the compiler
|
||||
warnings are the RED executable contract for the suppression/migration work.
|
||||
2. Keep descriptor reference equality and add constructor-only
|
||||
`@SuppressWarnings("ReferenceEquality")` with an invariant rationale.
|
||||
3. Qualify every ambiguous nested `ExpectedKind` reference with its enclosing record.
|
||||
4. Keep Spring Session's `<T> T getAttribute(String)` signature and add method-only
|
||||
`TypeParameterUnusedInFormals` suppression.
|
||||
5. Preserve defensive copying for the four `VersionedRedisSessionStore` array records; apply exact
|
||||
`ArrayRecordComponent` suppressions to those records and the private test fake only.
|
||||
6. Convert canonical finite score strings to `BigDecimal`, build inclusive Lettuce `Range` values,
|
||||
and use typed `zcount` and `zrangebyscoreWithScores(..., Limit.create(...))` overloads. Extend the
|
||||
runtime proxy test to prove both overloads and their offset/count arguments.
|
||||
7. Replace one-shot `new SecureRandom()` with one static final instance.
|
||||
8. Run:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:outbound:cache-redis:test --console=plain
|
||||
./gradlew :adapter:outbound:cache-redis:compileJava \
|
||||
:adapter:outbound:cache-redis:compileTestJava --rerun-tasks --console=plain
|
||||
./gradlew :adapter:outbound:cache-redis:spotbugsTest --rerun-tasks --console=plain
|
||||
```
|
||||
|
||||
## Task 4: Preserve HTTP retry and notification ciphertext invariants
|
||||
|
||||
**Owner leaves:** `adapter-outbound-httpclient`, `adapter-outbound-persistence-jpa`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java`
|
||||
- Modify: `src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicySpec.groovy`
|
||||
- Modify: `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java`
|
||||
- Modify: `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Add a characterization test using two `OutboundRetryPolicy` instances on one thread: policy A
|
||||
context must not be visible to policy B, and `endCall()` must clear the owning context. It should
|
||||
pass before implementation and justifies preserving the instance field; the compile warning is
|
||||
the RED contract.
|
||||
2. Keep the instance `ThreadLocal`; add field-only `ThreadLocalUsage` suppression with the isolation
|
||||
reason.
|
||||
3. Add/strengthen tests proving `NotificationCiphertext` clones nonce/ciphertext inputs and
|
||||
accessors, compares arrays by content, hashes consistently, and never exposes bytes in
|
||||
`toString()`.
|
||||
4. Keep the record API and add exact record-level `ArrayRecordComponent` suppression.
|
||||
5. Run:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:outbound:httpclient:test --console=plain
|
||||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||||
```
|
||||
|
||||
## Task 5: Migrate Jackson 3 messaging APIs
|
||||
|
||||
**Owner leaf:** `adapter-outbound-messaging`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/schema/LocalJsonSchemaRegistry.java`
|
||||
- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java`
|
||||
- Modify: `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java`
|
||||
- Modify: `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Extend existing tests to freeze text-node validation and canonical envelope bytes.
|
||||
2. Replace `isTextual()`/`textValue()` with `isString()`/`stringValue()`.
|
||||
3. Replace `createGenerator(output)` with
|
||||
`createGenerator(ObjectWriteContext.empty(), output, JsonEncoding.UTF8)`.
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:outbound:messaging:test --console=plain
|
||||
./gradlew :adapter:outbound:messaging:compileJava --rerun-tasks --console=plain
|
||||
```
|
||||
|
||||
## Task 6: Preserve legacy object storage and migrate provider APIs
|
||||
|
||||
**Owner leaves:** `application-core`, `adapter-outbound-objectstorage`, `sample-portfolio`,
|
||||
`app-bootstrap` architecture tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java`
|
||||
- Modify the six Java files under
|
||||
`src/application-core/src/main/java/dev/caskeleton/application/storage/migration/`.
|
||||
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspector.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java`
|
||||
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java`
|
||||
- Modify: `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactory.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java`
|
||||
- Modify: `src/adapter/outbound/objectstorage/build.gradle`
|
||||
- Modify: `src/adapter/outbound/objectstorage/gradle.lockfile` only if the toxiproxy dependency graph changes.
|
||||
- Modify audited URL, Mockito varargs, range parser, text-block, and legacy characterization tests.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Add/retain lifecycle tests: `ObjectStoragePort`, `StoredObject`, and adapter-owned
|
||||
`ObjectStorageSettings` remain `forRemoval=true`; migration types remain deprecated but are no
|
||||
longer `forRemoval`.
|
||||
2. Change the six migration mechanism types plus `AdoptLegacyPosterImageUseCase` to plain
|
||||
`@Deprecated`. Add only exact `deprecation` suppressions at adoption implementation/configuration
|
||||
consumers.
|
||||
3. Add only the exact `removal` suppressions named by the design to legacy implementations,
|
||||
controller/mapper/wiring, characterization classes, and single receipt methods.
|
||||
4. Replace AWS `RetryPolicy`/old equal-jitter API with `StandardRetryStrategy`, half-jitter
|
||||
exponential backoff, exact max attempts, and `retryStrategy(...)`. Assert normal/throttling
|
||||
configuration in `S3AsyncClientFactoryTest`.
|
||||
5. Keep the existing `org.testcontainers:testcontainers-toxiproxy` dependency, switch to its
|
||||
Testcontainers 2 package, and use `ToxiproxyClient`/`Proxy` against an explicitly exposed proxy
|
||||
port. Preserve cut/restore MinIO semantics; update the leaf lock only if resolution actually
|
||||
changes.
|
||||
6. Replace `new URL(String)` with `URI.create(...).toURL()`.
|
||||
7. Replace Mockito's two-value varargs `thenReturn` with two chained single-value stubs.
|
||||
8. Replace test-only range splitting with an asserted single-hyphen boundary; keep fingerprint
|
||||
literal bytes identical when converting to a text block.
|
||||
9. Run:
|
||||
|
||||
```bash
|
||||
./gradlew :application-core:test :adapter:outbound:objectstorage:test \
|
||||
:sample-portfolio:test --console=plain
|
||||
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew verifyDependencyLocks --console=plain
|
||||
```
|
||||
|
||||
10. If Docker is available, run the MinIO fault source-set task. If unavailable, record the exact
|
||||
environmental blocker; never suppress its deprecation to claim success.
|
||||
|
||||
## Task 7: Remove remaining mechanical Error Prone warnings
|
||||
|
||||
**Owner leaves:** `application-core`, `app-bootstrap`, `sample-portfolio`, and the exact test leaves
|
||||
from the audit inventory
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `IdempotencyExecutor.java`, `IdempotencySettings.java`,
|
||||
`SampleIdempotencySettings.java`, and matching tests.
|
||||
- Modify: `TracingSampleRateResolver.java` and `TestTaxonomyArchitectureTest.java`.
|
||||
- Modify: `CleanArchitectureTest.java`, `ManagementActuatorSecurityContractTest.java`,
|
||||
`ProblemDetailDisabledConfigTest.java`, and the serialization violation fixture.
|
||||
- Modify: `CreateWorkLogOutboxTest.java`, `WorkLogUseCasesTest.java`, and the remaining exact sample
|
||||
test warning locations.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Replace five `Duration.ofHours(72)` sites with `Duration.ofDays(3)`.
|
||||
2. Add the missing Javadoc summary and render annotation names as `{@code @WebMvcTest}`.
|
||||
3. Add all 16 missing `@Override` annotations.
|
||||
4. Replace Boolean wrapper comparison with the direct literal/assertion form.
|
||||
5. Preserve the forbidden `new BigDecimal(double/float)` bytecode and add method-only
|
||||
`BigDecimalLiteralDouble` suppressions with fixture rationale.
|
||||
6. Replace the three test-only one-argument splits without changing each grammar:
|
||||
limit-bearing CSV handling, equivalent mapping-path scanning, and exact byte-range parsing.
|
||||
7. Run affected owner tests and rerun all compile tasks with Error Prone:
|
||||
|
||||
```bash
|
||||
./gradlew :application-core:test :app-bootstrap:test :sample-portfolio:test --console=plain
|
||||
./gradlew compileJava compileTestJava --rerun-tasks --console=plain
|
||||
```
|
||||
|
||||
## Task 8: Capture Redis lab expected failures and configure clean test JVMs
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `infra/redis-lab/test/redis-lab-contract.sh`
|
||||
- Add: `src/gradle/test-jvm-agents.gradle`
|
||||
- Modify: `src/build.gradle`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Change `assert_fails` to capture stdout/stderr per invocation, require non-zero status, assert the
|
||||
exact expected diagnostic with no extra lines, and print capture only on mismatch.
|
||||
2. Run `bash -n infra/redis-lab/test/redis-lab-contract.sh`, then run the real Redis lab Gradle/shell
|
||||
contract and verify successful output contains no leaked `redis-lab:` child diagnostics.
|
||||
3. Add a dedicated `mockitoAgent` configuration per Java test project and a relocatable
|
||||
`CommandLineArgumentProvider` in `src/gradle/test-jvm-agents.gradle`. Require exactly one
|
||||
`mockito-core` jar and emit `-javaagent:<absolute jar>` plus test-only `-Xshare:off`.
|
||||
4. Apply the script once from the root build and wire every ordinary/custom `Test` task without
|
||||
changing production JVM arguments.
|
||||
5. Run representative Mockito-heavy app-bootstrap, Redis, object-storage, and messaging tests and
|
||||
verify no self-attachment/CDS warning is printed.
|
||||
|
||||
## Task 9: Enable warning-zero blocking gates
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/build.gradle`
|
||||
- Modify: `src/app-bootstrap/build.gradle`
|
||||
- Modify: `.github/workflows/ci-quality-gates.yml`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. First run every `JavaCompile` task with `-Xlint:deprecation` and `-Xlint:unchecked`; resolve every
|
||||
remaining diagnostic at the exact source owner.
|
||||
2. Add `-Werror`, `-Xlint:deprecation`, and `-Xlint:unchecked` to every leaf `JavaCompile` task while
|
||||
retaining Error Prone.
|
||||
3. Remove root `checkstyleTest` and `spotbugsTest` `ignoreFailures=true`.
|
||||
4. Remove app-bootstrap `sampleOffTest`, `functionalTest`, and `conditionalTransportTest`
|
||||
Checkstyle/SpotBugs ignore overrides. Keep only `quarantineTest` non-blocking.
|
||||
5. Add `--warning-mode=fail` to the blocking `quality-gates` Gradle invocation.
|
||||
6. Run:
|
||||
|
||||
```bash
|
||||
./gradlew checkstyleTest spotbugsTest --rerun-tasks --console=plain
|
||||
./gradlew check --warning-mode=fail --no-daemon --console=plain
|
||||
```
|
||||
|
||||
## Task 10: Fresh repository verification, review, and Wiki capture
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/plans/2026-08-02-warning-zero-build-refactoring.md` only if execution
|
||||
evidence exposes a plan correction.
|
||||
- Modify external Wiki capture:
|
||||
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md`
|
||||
and `raw/errors/build-success-warning-debt-2026-08-02.md`.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Run owner-focused tests for every changed leaf.
|
||||
2. Run repository verification from `src/`:
|
||||
|
||||
```bash
|
||||
./gradlew test --no-daemon --console=plain
|
||||
./gradlew check --no-daemon --console=plain
|
||||
./gradlew build --warning-mode=fail --no-daemon --console=plain
|
||||
./gradlew clean build --warning-mode=all --no-daemon --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership \
|
||||
verifyDependencyLocks verifyPublicPathSnapshot verifyEnvKeys \
|
||||
--no-daemon --console=plain
|
||||
```
|
||||
|
||||
3. Verify the gate matrix, wrapper, shell syntax, XML findings/skips, and diff:
|
||||
|
||||
```bash
|
||||
bash .github/scripts/verify-gate-matrix.sh
|
||||
bash .github/scripts/verify-gradle-wrapper.sh .
|
||||
bash -n infra/redis-lab/test/redis-lab-contract.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
4. Scan the fresh build log for `warning:`, deprecated/unchecked `Note:`, SpotBugs non-zero output,
|
||||
OpenJDK/CDS warnings, Mockito self-attachment, and leaked expected-negative Redis diagnostics.
|
||||
5. Confirm the skipped-test XML inventory is exactly the five approved optional-adapter contract
|
||||
cases and no qualification source set skipped.
|
||||
6. Dispatch independent code review over behavior fixes, legacy/provider migrations, and
|
||||
Gradle/test-noise gates. Apply only evidence-backed findings and rerun affected/full gates.
|
||||
7. Update the mandatory Wiki branch/error notes with changed files, commands, results, suppression
|
||||
inventory, blocked environment-only qualifications, and evidence grade. Run per-file Wiki lint;
|
||||
retain the known `main.md` naming-policy conflict without weakening either policy.
|
||||
8. Report success only if the clean build is exit zero and the final log is warning/noise clean.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Web Security Boundary Implementation Plan
|
||||
|
||||
> **Execution:** Follow test-driven development and request an independent read-only review before
|
||||
> advancing to Redis session/CSRF.
|
||||
|
||||
**Goal:** Make JWT/JWKS and CORS filter-boundary behavior hermetic, release-blocking, and impossible
|
||||
to skip silently.
|
||||
|
||||
**Architecture:** Tests remain in inbound-web, use only existing dependencies, and cross the real
|
||||
Spring Security filter chain. A tagged Gradle task isolates them from the ordinary unit suite.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 4.0.0, Spring Security 7, Nimbus JOSE JWT, JDK HttpServer,
|
||||
MockMvc, Gradle 9.
|
||||
|
||||
### Task 1: Dedicated No-Skip Test Gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapter/inbound/web/build.gradle`
|
||||
|
||||
- [x] Register `webSecurityBoundaryTest` over `sourceSets.test` with tag inclusion, no-discovery
|
||||
failure, no up-to-date reuse, UTC, and a root-suite skipped-count guard.
|
||||
- [x] Exclude `security-boundary` from ordinary `test` and require the dedicated task from `check`.
|
||||
- [x] Confirm 13 tagged tests are discovered with zero skips and no dependency/lock entry is added.
|
||||
|
||||
### Task 2: JWT/JWKS RED Contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtJwksSecurityFilterIntegrationTest.java`
|
||||
|
||||
- [x] Add a loopback OIDC discovery/JWKS server with request counters and deterministic 503 mode.
|
||||
- [x] Add RS256 token generation using ephemeral keys and conspicuous secret sentinels.
|
||||
- [x] Prove lazy startup and valid bearer-to-principal conversion.
|
||||
- [x] Prove exact expiry, issuer, audience, signature, unknown-kid, and JWKS-outage envelopes/headers.
|
||||
- [x] Prove same-context recovery after a first-request JWKS 503 and prove mismatched discovery
|
||||
metadata reaches the safe 500 `INTERNAL_AUTH_MISCONFIGURATION` filter boundary.
|
||||
- [x] Run the dedicated task and record RED: unknown kid was classified as signature failure and a
|
||||
first-request JWKS 503 escaped as `JwtDecoderInitializationException`/`AuthenticationServiceException`.
|
||||
|
||||
### Task 3: CORS RED Contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/CorsSecurityFilterIntegrationTest.java`
|
||||
|
||||
- [x] Prove approved credentialed preflight bypasses bearer authentication and emits exact headers.
|
||||
- [x] Prove denied origin, disabled CORS, wildcard-without-credentials, and approved actual-origin behavior.
|
||||
- [x] Assert bounded `Vary` behavior and no reflection of an unapproved sentinel origin.
|
||||
- [x] Run the dedicated task: all five CORS filter-boundary contracts passed without production changes.
|
||||
|
||||
### Task 4: Minimal Production Fixes and Verification
|
||||
|
||||
- [x] If RED exposes a production mismatch, change only the owning classifier/security configuration
|
||||
and keep stable error-code/header contracts intact.
|
||||
- [x] Run `webSecurityBoundaryTest`, ordinary inbound-web `test`, module static analysis, `check`,
|
||||
dependency-lock verification, architecture verification, and `git diff --check`.
|
||||
- [x] Request an independent read-only review; add the requested same-context recovery and non-I/O
|
||||
initialization-failure contracts, and bind the loopback server to an explicit IPv4 address.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,838 @@
|
||||
# Redis Wrapper and Typed API — repository adaptation and delivery status
|
||||
|
||||
- **Design:** `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`
|
||||
- **Plan:** `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md`
|
||||
- **Status date:** 2026-08-07
|
||||
- **All 27 tasks delivered.** Sections 13–24 record what each one decided and what the topology
|
||||
lanes found; `docs/redis/support-matrix.md` records which test produced which evidence.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why the structure differs from the plan
|
||||
|
||||
The design and plan were written without the target repository attached, so they assume a
|
||||
`backend-skeleton/` root with twelve standalone Gradle projects under `modules/redis/`, Kotlin DSL
|
||||
build files, and the `io.backend.skeleton.redis` package root. The package README anticipates exactly
|
||||
this and instructs the implementer to keep the structural contract while conforming to whatever
|
||||
stronger rules the real repository already enforces.
|
||||
|
||||
This repository has three such rules, and all of them outrank the plan's file layout:
|
||||
|
||||
1. `src/config/architecture/modules.json` is a fail-closed registry of **exactly 19 leaf modules**,
|
||||
re-validated by `src/settings.gradle` on every configuration. Adding twelve Gradle projects would
|
||||
violate HARD-STOP condition 5 in `AGENTS.md`.
|
||||
2. The build is Groovy DSL with `dependencyLocking(STRICT)`, so the plan's `libs.versions.toml`
|
||||
entries and its Spring Data Redis 4.1 / Lettuce 7.6 pins cannot be introduced without regenerating
|
||||
lock state. The repository is on Spring Boot 4.0.0 with **Lettuce 6.8.1**.
|
||||
3. The package root is `dev.caskeleton`, not `io.backend.skeleton`.
|
||||
|
||||
The SDK therefore lives inside the already-registered `adapter:outbound:cache-redis` leaf, and each
|
||||
designed module is a package. What the separate Gradle projects would have enforced —
|
||||
dependency direction and driver containment — is enforced instead by
|
||||
`RedisSdkModuleBoundaryTest`, which reads the source tree and fails on a forbidden import.
|
||||
|
||||
### Module mapping
|
||||
|
||||
| Design module | Package under `dev.caskeleton.adapter.outbound.cache.redis.sdk` |
|
||||
| --- | --- |
|
||||
| `redis-core-api` | `api`, `api.key`, `api.codec`, `api.command`, `api.error`, `api.operations`, `api.reactive` |
|
||||
| `redis-core-lettuce` | `lettuce.codec`, `lettuce.command`, `lettuce.connection`, `lettuce.observability` |
|
||||
| `redis-spring-boot-starter` | `config` |
|
||||
| `redis-cluster` | `cluster` |
|
||||
| `redis-programmability` | `programmability` |
|
||||
| `redis-raw-gateway` | `raw` |
|
||||
| `redis-admin-plane` | `admin` |
|
||||
| `extensions/*` | `extensions.json`, `extensions.search`, `extensions.timeseries`, `extensions.probabilistic` |
|
||||
| `redis-testkit` | `src/test` and the existing `redisTest` source set |
|
||||
|
||||
### Other adaptations, and the reason for each
|
||||
|
||||
| Plan says | Repository does | Why |
|
||||
| --- | --- | --- |
|
||||
| `backend.redis.*` properties | `ca-skeleton.capabilities.redis-sdk.*` | Matches the existing capability property namespace and avoids colliding with `app.cache.redis`. |
|
||||
| `RedisEnvelope` is a record with a `byte[]` component | Value class with the same accessors | ErrorProne `ArrayRecordComponent` is a blocking check in this build. |
|
||||
| Jackson-based YAML policy loader | Explicit strict reader for a closed YAML subset | No Jackson or SnakeYAML on the main compile classpath, and a general YAML engine would accept anchors, merges, and duplicate keys inside a security policy file. |
|
||||
| `VersionedJsonCodec` maps objects reflectively | Frames a versioned JSON envelope around a caller-supplied `RedisPayloadCodec` | Same guarantee — schema id, version, size ceiling, hard failure on an unknown version — without an object mapper the module cannot depend on. |
|
||||
| Each task ends with `git commit` | No commits | `AGENTS.md` commit policy is `human-only`. |
|
||||
| Gradle tasks `redis72Test` … `cluster82Test` | Not registered | They belong to Task 8's testkit half and Task 26; both need Docker-backed Testcontainers, which Milestone A does not reach. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Task status
|
||||
|
||||
| Task | Title | Status |
|
||||
| --- | --- | --- |
|
||||
| 1 | Module graph and shared quality rules | **Done** as a package graph plus `RedisSdkModuleBoundaryTest` |
|
||||
| 2 | Command policy catalog and metadata diff | **Done** |
|
||||
| 3 | Version, topology, risk, permit, budget models | **Done** |
|
||||
| 4 | Key namespace and slot-safe typed keys | **Done** |
|
||||
| 5 | Codec registry and versioned envelope | **Done** |
|
||||
| 6 | Stable error model and ambiguous execution | **Done** |
|
||||
| 7 | Sync and reactive public API with parity test | **Done** |
|
||||
| 8 | Properties, capability probe, connection isolation, permit authority | **Done** except the Testcontainers topology environments and their Gradle tasks |
|
||||
| 9 | Policy-aware executor and observability | **Done** |
|
||||
| 10 | String and Key/TTL operations, blocking and reactive | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 11 | Hash operations and the 7.4 field-TTL version gate | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 12 | Set and Sorted Set operations, blocking and reactive | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 13 | List operations and the bounded blocking lane | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 14 | Bitmap, bitfield, HyperLogLog, and geospatial operations | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 15 | Batch and pipeline | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 16 | Stream | **Done** against the in-memory gateway, including the Redis 8.2 deletion capability; `XNACK` (8.8) deferred, see §13 |
|
||||
| 17 | Pub/Sub and sharded Pub/Sub | **Done** against the in-memory bus; no real-server evidence |
|
||||
| 18–19 | Sentinel failover certainty, Cluster slot/redirect/topology | **Done** as pure logic with unit evidence; the fault-injection lane is Task 26 |
|
||||
| 21 | Registered scripts and functions | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 20 | Transactions | **Done**, with the fixture reworked to defer inside a MULTI window, see §24 |
|
||||
| 22 | Approved raw gateway | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 23 | Isolated admin plane | **Done** against the in-memory gateway; no real-server evidence |
|
||||
| 24–25 | JSON, Search, Time Series, Probabilistic extensions | **Done** against the in-memory gateway; no real-module evidence |
|
||||
| 26–27 | Topology/fault/ACL/performance harness, CI matrix and docs gates | **Done** — all three lanes have produced evidence on 7.4, see §21–§23 |
|
||||
|
||||
`RedisSdkModuleBoundaryTest.NOT_YET_IMPLEMENTED_MODULES` is the machine-checked version of the
|
||||
"not started" rows: the test fails if a listed package appears without the list being updated, and
|
||||
fails if an unlisted one is missing.
|
||||
|
||||
---
|
||||
|
||||
## 3. What Milestone A actually guarantees
|
||||
|
||||
- Every command the SDK will ever run is classified in
|
||||
`src/main/resources/redis-sdk/redis-command-policy.yml`. An unclassified command is refused by
|
||||
`RedisCommandCatalog`, so a Redis upgrade cannot make a new command reachable by default.
|
||||
- `KEYS`, `FLUSHALL`, `FLUSHDB`, `SHUTDOWN`, `DEBUG`, `EVAL`, `CONFIG SET`, and the deprecated
|
||||
command names are `BLOCKED` with ACL account `NONE`.
|
||||
- R2 commands cannot execute without both an issued permit and an `OperationBudget`, and a permit the
|
||||
caller implemented itself fails provenance verification.
|
||||
- Sync and reactive typed API surfaces are mechanically proven to be in parity.
|
||||
- Metric and trace tags are a closed low-cardinality set with no key, field, member, or value in it.
|
||||
- A write that timed out is reported as `RedisAmbiguousExecutionException` with `retryable=false`,
|
||||
and `RedisFailureMetadata` rejects the retryable-and-ambiguous combination at construction.
|
||||
|
||||
## 4. What Milestone A does not guarantee
|
||||
|
||||
- No command has been executed against a real Redis server by this work. Every test is a unit or
|
||||
contract test over fakes; the contract suites the plan defines for Tasks 10–17 do not exist yet.
|
||||
- The typed operation interfaces have no implementation, so `RedisOperations` cannot be wired into a
|
||||
Spring context yet. `RedisSdkSettings` is bound but no bean registration reads it.
|
||||
- Cluster slot calculation is a caller-supplied function; the CRC16 implementation is Task 19.
|
||||
## 5. Cleanup of everything the design does not specify
|
||||
|
||||
The leaf previously carried five Redis capabilities that this design does not describe — semantic
|
||||
cache, session, request-replay idempotency, soft lease, and edge rate limit — together with their
|
||||
evidence and readiness governance. All of it is removed, so the Redis surface is now exactly the
|
||||
SDK.
|
||||
|
||||
| Removed | Scale |
|
||||
| --- | --- |
|
||||
| `cache-redis` non-SDK sources, tests, Lua programs, and the `redisTest` evidence source set | 188 main + 105 test + 18 evidence Java files, 52 resources |
|
||||
| `cache-redis/build.gradle` | 626 lines → 22; ~50 evidence/readiness lanes gone |
|
||||
| `app-bootstrap` Redis wiring, health contributor, material providers, `redisCompositionTest` source set | 15 files plus its Gradle tasks and configurations |
|
||||
| `application-core/src/redisPolicyContractTest` | 1 file plus its source set |
|
||||
| Root `build.gradle` Redis readiness/evidence/CI-matrix governance | 1,420 lines |
|
||||
| `config/redis/`, `gradle/redis-test-images.properties`, `infra/redis-lab/`, `.github/workflows/redis-production-readiness.yml` | removed |
|
||||
| `ci-quality-gates.yml` / `ci-gate-matrix.yml` | `redis-standalone` job retargeted to `redis-sdk` |
|
||||
|
||||
Kept deliberately: `shared-contract`'s `EdgeRateLimitPort` and its provider-neutral contract test.
|
||||
It is a rate-limit port, not a Redis type, and the design's exclusion list covers business policy
|
||||
rather than application ports.
|
||||
|
||||
Verified after the cleanup: `./gradlew test`, `verifyCleanArchitectureDependencies`,
|
||||
`verifyEnvKeys`, `verifyDependencyLocks` all pass; `verify-gate-matrix.sh` reports 27 gates OK.
|
||||
Dependency locks were regenerated for every module.
|
||||
|
||||
## 6. Task 10 — decisions a reviewer should check
|
||||
|
||||
The string and key/TTL operations landed in `sdk.lettuce.operations`, which is the package form of
|
||||
the plan's `redis-core-lettuce/.../lettuce/operations`. Five things differ from a literal reading of
|
||||
the plan, each for a stated reason.
|
||||
|
||||
| Decision | Why |
|
||||
| --- | --- |
|
||||
| A narrow `RedisCommandGateway` seam sits between the typed operations and Lettuce; `LettuceRedisCommandGateway` is the only class that touches the driver. | The plan's contract suites run on Testcontainers, which this environment has no lane for. The seam lets the whole policy path — catalog, permit provenance, budget, admission order, decode — be proven deterministically, and it keeps driver containment real rather than asserted. It is not a substitute for the real-server evidence Task 26 owns. |
|
||||
| Where design section 10 gives an R2 method only a permit (`multiGet`, `delete`, `unlink`, `rename`, `scan`) or only a budget (`append`, `getRange`, `setRange`), the SDK fills the missing half. | `CommandPolicyGuard` requires both for every R2 command. The caller-supplied half always wins; the other comes from `RedisOperationLimits` or a permit the SDK itself holds. Without this, half the designed R2 surface could not be admitted at all. |
|
||||
| Increment-with-initial-TTL runs a registered Lua script, and the SDK loads that script itself inside the guarded `EVALSHA` invocation. | Redis 7.2–8.2 has no `INCR` variant carrying an expiry, and both two-command sequences leak a permanent counter on a crash. The `SCRIPT LOAD` that resolves the digest is therefore *not* separately admitted by the guard — it travels under the `EVALSHA` admission with the same `registered-script` permit and script budget. Proper script registration is Task 20/21's `programmability` module; this is the narrowest thing that makes the operation correct in the meantime. |
|
||||
| No `INCREX` version-gated path. | No shipped Redis version has the command, so it is in neither the policy catalog nor `RedisCapability`. Adding a gate for a command that does not exist would be untestable. |
|
||||
| `expire`/`expireAt` report `ABSENT` only when the condition was `ALWAYS`. | Redis answers `0` both for a missing key and for an unmet condition. With `ALWAYS` the only possible cause is a missing key; with any other condition the SDK reports `CONDITION_NOT_MET` rather than guessing. A non-positive TTL is refused outright instead of silently deleting the key. |
|
||||
|
||||
Coverage: 30 new tests (`RedisValueOperationsContractTest`, `RedisKeyOperationsContractTest`) over
|
||||
permit provenance, budget ceilings, atomic counter creation, script reload after `NOSCRIPT`,
|
||||
namespace-bounded paging, and blocking/reactive agreement. `lettuce/operations` is registered in
|
||||
`RedisSdkModuleBoundaryTest.DESIGNED_MODULES`.
|
||||
|
||||
## 7. Task 11 — the field-TTL version gate
|
||||
|
||||
The gate design section 10.2 asks for is applied in two independent places, because either one alone
|
||||
is weaker than it looks.
|
||||
|
||||
- `LettuceRedisHashFieldExpirationOperations.ifSupported(...)` returns empty below Redis 7.4, so a
|
||||
composition root has nothing to inject and a caller cannot hold the API at all. This is the
|
||||
"bean is absent on 7.2" property the plan's Redis 7.2 test asserts.
|
||||
- `HEXPIRE`, `HPEXPIRE`, `HPERSIST`, `HTTL`, and `HPTTL` carry `minimum-version: "7.4"` in the policy
|
||||
catalog, so `CommandPolicyGuard` refuses them on an older server even for a hand-built instance.
|
||||
`guardRefusesFieldExpiryOnAnOlderServer` proves that second layer by forcing an instance into
|
||||
existence against a 7.2 server and watching the guard reject it.
|
||||
|
||||
`entries` is R2 with a caller-supplied permit *and* budget, exactly as designed — it is the one hash
|
||||
method the design gives both, so nothing is filled in for it. `HSCAN` gets the Task 10 treatment: an
|
||||
SDK `cursor-scan` permit and a budget derived from the requested page, because
|
||||
`scan(HashKey, ScanRequest)` carries neither. `HGETALL` and `HSCAN` replies are measured against the
|
||||
budget before decoding, so an oversized hash is refused rather than materialised.
|
||||
|
||||
One Lettuce accommodation is worth knowing about: its only batched `HSET` takes a `Map`, which for a
|
||||
`byte[]`-keyed connection means identity hashing. The seam therefore passes two positional lists and
|
||||
`LettuceRedisCommandGateway.hashPutAll` is the single place that builds the map — never reading from
|
||||
it, only iterating — with the ErrorProne check suppressed there and nowhere else.
|
||||
|
||||
## 8. Task 12 — the range commands are encoded, not borrowed
|
||||
|
||||
Design section 10.5 requires `rangeByScore`, `rangeByLex`, and a descending `rangeByRank`. Lettuce
|
||||
6.8 has no typed `ZRANGE ... BYSCORE / BYLEX / REV`; its only typed paths are the deprecated
|
||||
`ZRANGEBYSCORE`, `ZREVRANGEBYSCORE`, `ZRANGEBYLEX`, `ZREVRANGEBYLEX`, and `ZREVRANGE`.
|
||||
|
||||
Those five stay `BLOCKED`, exactly like `SETNX`, `GETSET`, `HMSET`, `RPOPLPUSH`, and `GEORADIUS`.
|
||||
`LettuceRedisCommandGateway` encodes the modern command itself — `ZRANGE key min max
|
||||
BYSCORE|BYLEX [REV] LIMIT offset count [WITHSCORES]` — through Lettuce's typed `dispatch` with a
|
||||
fixed `CommandType.ZRANGE`, a fixed output, and arguments built from the already-rendered key. Every
|
||||
range read therefore declares `ZRANGE` to the guard and sends `ZRANGE` on the wire, so the ACL
|
||||
account and the catalog drift gate stay aligned with reality.
|
||||
|
||||
This is not the forbidden raw-command surface: there is no method anywhere that accepts a command
|
||||
name, and the encoding lives in the one class that is already allowed to know the driver.
|
||||
|
||||
Everything else in Task 12 follows Task 10's rules. `SMEMBERS` has no method at all — the API offers
|
||||
`scan` or permit-and-budget set algebra, and a test asserts no whole-set reader exists.
|
||||
`SRANDMEMBER`, `SSCAN`, and `ZSCAN` get an SDK permit plus a derived budget because their signatures
|
||||
carry neither; `SMOVE` takes the caller's multi-key permit; `SDIFF`/`SINTER`/`SUNION` and every range
|
||||
read take both from the caller, and the reply is measured against the budget before it is decoded.
|
||||
|
||||
## 9. Task 13 — the blocking lane
|
||||
|
||||
`LettuceRedisBlockingListOperations` takes its own `RedisCommandGateway`, which the composition root
|
||||
binds to a connection borrowed from `RedisConnectionKind.BLOCKING`. That parameter is the structural
|
||||
form of design section 10.3's "separate bean, dedicated pool": a command that occupies its
|
||||
connection until the server answers cannot be issued down the lane ordinary traffic shares, and the
|
||||
type system is what stops it rather than a convention.
|
||||
|
||||
An unbounded wait is impossible on three independent levels: the request always declares its block,
|
||||
`ListOperationRequests` refuses a non-positive one before building anything, and
|
||||
`CommandPolicyGuard` refuses a block above the configured ceiling and sets the client timeout to the
|
||||
block plus `TimeoutProfile.BLOCKING_MARGIN`. All three are asserted.
|
||||
|
||||
`BLMOVE` needs both authorisations and the design gives the caller only one, so the caller's
|
||||
multi-key permit is verified in the operations layer while the SDK supplies the `blocking-pop`
|
||||
permit the guard demands. Crossing two keys and occupying a connection are separate decisions and
|
||||
the caller must still hold the first.
|
||||
|
||||
## 10. Task 14 — the ceiling that matters
|
||||
|
||||
A single `SETBIT` at an arbitrary offset allocates the whole prefix, so an unchecked offset is a
|
||||
memory-exhaustion primitive rather than a write. `RedisOperationLimits.maxBitmapOffset` bounds every
|
||||
bit offset — `GETBIT`, `SETBIT`, and each `BITFIELD` subcommand — before a command is built, and a
|
||||
negative offset is refused outright.
|
||||
|
||||
`BITOP`, `PFCOUNT`, `PFMERGE`, and `GEOSEARCHSTORE` take the caller's multi-key permit; `BITCOUNT`,
|
||||
`BITPOS`, `BITFIELD`, and `GEOSEARCH` take the caller's budget with an SDK permit. A geo search is
|
||||
bounded three ways — its own `count`, the collection ceiling, and the caller's budget measured
|
||||
against the reply before decoding.
|
||||
|
||||
## 11. Task 15 — the two decisions the design left open
|
||||
|
||||
`RedisBatch` exposes only `size()`, `keys()`, and `requestBytes()`, so it is opaque: nothing in the
|
||||
public contract lets a caller put commands into one. The SDK therefore owns both the concrete batch
|
||||
and the only way to fill it, and two questions had to be answered.
|
||||
|
||||
**What the builder covers.** `LettuceRedisBatch.Builder` covers the string, key, and hash surfaces
|
||||
rather than mirroring all ~80 typed methods. Those are what pipelining is actually used for, each
|
||||
extra method is one delegating line onto the existing request factories, and widening it later is
|
||||
mechanical rather than a redesign. A batch built anywhere else is refused.
|
||||
|
||||
**Whether R2 commands may be batched.** They may, carrying their own permit and budget exactly as
|
||||
they do alone. `BatchOptions` has no permit field, so the alternative was an R1-only batch — which
|
||||
would have blocked the case where saving a round trip matters most. Both ceilings apply and the
|
||||
smaller wins: the guard refuses an item that broke its own budget before the batch ceiling is even
|
||||
checked.
|
||||
|
||||
Four properties are enforced rather than documented. Every item is admitted **before** any command
|
||||
is sent, so one refused item cancels the batch instead of leaving it half-applied. Input index is
|
||||
result index, failure or not. Items fail independently — `hasPartialFailure` is the caller's signal,
|
||||
not an exception. And there is no retry path in the class at all, so a failed write is never
|
||||
re-sent.
|
||||
|
||||
## 12. Task 17 — a subscription is not a command
|
||||
|
||||
Publishing goes through the guard like anything else. Subscribing does not: it has no reply to bound
|
||||
and no timeout to apply, it occupies its connection for as long as it lives, and it therefore has
|
||||
its own seam — `RedisPubSubGateway`, bound to a connection borrowed from
|
||||
`RedisConnectionKind.PUBSUB`. A long-lived listener can never sit on the lane ordinary commands use.
|
||||
|
||||
What the guard would have checked is checked in `PubSubOperationRequests` instead: every channel and
|
||||
pattern must belong to the process namespace, an empty subscription is refused, and a pattern
|
||||
subscription demands the `pattern-subscribe` permit because the server decides how much a pattern
|
||||
matches.
|
||||
|
||||
Lifecycle is the part that leaks if it is only documented. The blocking API returns an
|
||||
`AutoCloseable` `Subscription`; the reactive API returns a `Flux` whose cancellation closes the
|
||||
driver handle. Both are asserted against a bus that reports how many subscriptions are still open,
|
||||
so an abandoned subscriber releasing its connection is a test, not a claim.
|
||||
|
||||
Sharded Pub/Sub is gated exactly like per-field expiry: `ifSupported` yields nothing below Redis
|
||||
7.0, and `SPUBLISH` carries the same minimum in the catalog so the guard refuses it independently.
|
||||
|
||||
### Still outstanding
|
||||
|
||||
`application.yml`, `.env`, and `docs/registries/env-keys.yaml` still carry the property blocks of
|
||||
the five removed capabilities. They bind nothing and the build is green with them present, but they
|
||||
are dead configuration and should go in the same sweep that removes the corresponding capability
|
||||
sections.
|
||||
|
||||
## 13. Task 16 — a stream entry, a payload field, and one command that had to be encoded
|
||||
|
||||
**One payload field.** `StreamKey<V>` carries exactly one payload codec and `StreamRecord<V>`
|
||||
exactly one value, so the SDK writes exactly one field, named `payload` in
|
||||
`StreamOperationRequests` and nowhere else. An entry that comes back with any other shape is
|
||||
refused rather than half-decoded: a foreign producer's record is an anomaly the caller has to see,
|
||||
not something to silently truncate into a `StreamRecord`.
|
||||
|
||||
**`XREAD` forced a catalog distinction.** `BLPOP` has no non-blocking form, so a request that omits
|
||||
its block is a defect. `XREAD` does have one — the same command name is an ordinary bounded read
|
||||
without `BLOCK`. The catalog previously modelled only "blocking", which would have meant either
|
||||
rejecting every non-blocking stream read or excusing the stream reads from the rule that nothing
|
||||
waits forever. Both were wrong, so `optional-block` was added to the policy schema and
|
||||
`RedisCommandPolicy.requiresServerBlock()` now separates the two. `XREAD` and `XREADGROUP` are the
|
||||
only commands that carry it. The blocking bean still takes a non-nullable `Duration`, and the guard
|
||||
still refuses a non-positive block or one over the configured ceiling.
|
||||
|
||||
**A group read has exactly two legal offsets.** `NewForGroup` and `PendingForConsumer` are accepted;
|
||||
`After` and `Latest` are refused. Reading a group from an arbitrary identifier would hand a consumer
|
||||
entries the group already distributed elsewhere without moving the pending list — a duplicate
|
||||
delivery the caller did not ask for. The mirror rule holds for the group-free read, which refuses
|
||||
the two group offsets.
|
||||
|
||||
**`XAUTOCLAIM` is encoded, not borrowed.** Lettuce's typed `xautoclaim` returns `ClaimedMessages`,
|
||||
which drops the third reply element: the identifiers that were pending but no longer exist in the
|
||||
stream. `ClaimResult.deletedIds` is part of the SDK contract precisely because a consumer that
|
||||
cannot see that list keeps sweeping the same tombstones forever. The command is therefore built in
|
||||
`LettuceRedisCommandGateway` with `NestedMultiOutput`, the same precedent set by the sorted-set
|
||||
ranges in §8 — the command declared to the guard is still the command on the wire, and no method
|
||||
accepts a command name.
|
||||
|
||||
**Permits and budgets.** `XTRIM` runs under `bounded-collection-write`, the ranges under
|
||||
`bounded-collection-read`, both reads under the new `stream-read` policy, and `XPENDING`/`XAUTOCLAIM`
|
||||
under `stream-recovery`. None of the design's stream signatures carry a caller permit, so all four
|
||||
are SDK permits; the caller-supplied bound is the mandatory `count`, which becomes both the guard's
|
||||
budget and the ceiling checked against `maxCollectionElements`. There is no "read the whole stream"
|
||||
call that can be written against this API.
|
||||
|
||||
**Redis 8.2 deletion landed; 8.8 `XNACK` did not.** `XACKDEL`/`XDELEX` are behind
|
||||
`LettuceRedisStreamDeletionOperations.ifSupported(...)`, gated exactly like hash field expiry — the
|
||||
capability probe decides whether a bean exists, and the catalog's 8.2 minimum refuses a hand-built
|
||||
one. `XNACK` is deliberately not implemented: the pinned Lettuce 6.8.2 has no typed form for it, and
|
||||
unlike `XAUTOCLAIM` its wire format cannot be verified against a driver or a released server, so
|
||||
encoding it by hand would be inventing a protocol rather than adapting one. The capability, the
|
||||
catalog entry, and the 8.8 minimum stay in place; the bean is the only missing piece and should be
|
||||
added when the command is available in the driver or in a released server.
|
||||
|
||||
## 14. Tasks 18–19 — the parts that do not need a cluster to be true
|
||||
|
||||
Both tasks are specified against real Sentinel and Cluster environments, which this repository does
|
||||
not yet have a lane for. What landed is the half that is decidable without one, and it is the half
|
||||
the rest of the SDK depends on.
|
||||
|
||||
**The slot calculator is a pre-flight check, not a redirect handler.** `RedisSlotCalculator`
|
||||
computes CRC-16/XMODEM over the hash tag exactly as Redis does, so `CommandPolicyGuard` can refuse a
|
||||
cross-slot multi-key command before it is written. A server-side `CROSSSLOT` would arrive after the
|
||||
request left the process, which is precisely the outcome the guard exists to prevent. The seam was
|
||||
already there — the guard has always taken a `ToIntFunction<String>` — so this task filled it rather
|
||||
than changing the pipeline. The published slots for `foo`, `bar`, and `hello` are asserted, so a
|
||||
regression in the checksum shows up as a wrong number rather than as a cluster that quietly
|
||||
mis-routes.
|
||||
|
||||
**An empty tag is not a tag.** `{}` hashes the whole key, matching Redis, and that is tested,
|
||||
because the alternative — hashing an empty string — would collapse every such key onto one slot.
|
||||
|
||||
**A cluster scan is not a snapshot, and `ClusterScanCursor` refuses to pretend otherwise.** A sweep
|
||||
is complete only when every primary has *answered* with a zero cursor; a primary that was never
|
||||
asked counts as unfinished. Reporting completion after skipping a shard would let a caller conclude
|
||||
a key does not exist when a whole shard was never looked at.
|
||||
|
||||
**Redirect counting separates two different incidents.** A trickle of `MOVED` means the client's
|
||||
topology is stale; `ASK` and `TRYAGAIN` mean a resharding is in progress. The driver follows both
|
||||
transparently, so neither is visible to a caller — `ClusterTopologyObserver` is what makes them
|
||||
visible to an operator, and it accepts slot numbers and node identifiers only, never a key.
|
||||
|
||||
**`ExecutionCertainty` is the failover decision made explicit.** "The server refused it" and "the
|
||||
connection died after the command was written" look identical to a caller and have opposite
|
||||
consequences. `SentinelFailoverObserver.classify` returns `SAFE_TO_RETRY_FAILURE` only when the
|
||||
command provably never reached the server; anything written and unanswered is `AMBIGUOUS_FAILURE`,
|
||||
and `allowsAutomaticRetry` then defers to the command policy's `retry-safe` flag. A non-idempotent
|
||||
write is therefore never resent by the pipeline, and each one is counted so an operator knows how
|
||||
many need reconciling.
|
||||
|
||||
**The reconnect queue is bounded on purpose.** An unbounded queue turns a thirty-second promotion
|
||||
into a thirty-second backlog that lands at once on a freshly promoted primary. Refusals past the
|
||||
bound are counted so the bound can be tuned from evidence rather than guessed.
|
||||
|
||||
**What is still owed:** the fault-injection evidence. Nothing here proves how Lettuce actually
|
||||
behaves during a promotion or a resharding — that is a real-topology lane and belongs to Task 26.
|
||||
These types are the classification and accounting that lane will assert against.
|
||||
|
||||
## 15. Task 21 — scripts are a deployment artefact, and Task 20 is blocked on the fixture
|
||||
|
||||
**Nothing accepts a script body at call time.** `EVAL` is blocked in the command policy, so the only
|
||||
reachable path is `EVALSHA` of a digest that `RedisScriptRegistry` obtained from a `SCRIPT LOAD` of
|
||||
a reviewed `RegisteredRedisScript`. A script assembled from request data has the blast radius of the
|
||||
whole keyspace; making registration a deployment step is what turns "we only run reviewed scripts"
|
||||
from a convention into a structural property.
|
||||
|
||||
**Keys are declared, and that is what makes them checkable.** Every key goes into the request's key
|
||||
list, so a script is namespace-checked and same-slot-checked exactly like any other multi-key
|
||||
command. `RedisArgument` is a distinct type from a key for the same reason: a key smuggled through
|
||||
`ARGV` would bypass both checks, and having the two be different types is what makes that a compile
|
||||
problem rather than a review problem.
|
||||
|
||||
**A registered script returns one bulk reply.** That is a contract, not a limitation of
|
||||
`RedisResultDecoder`. A nested Lua table forces the SDK to guess how deep the reply is and how each
|
||||
level is typed, which is the ambiguity a typed API exists to remove. Encode the result and decode it
|
||||
in the decoder.
|
||||
|
||||
**`NOSCRIPT` is the one automatic retry in the SDK.** The server rejects the call before running
|
||||
anything, so reloading and re-issuing once repeats nothing. It is not a retry of an ambiguous write,
|
||||
and no other failure is retried on this path.
|
||||
|
||||
**Functions are callable, not loadable.** `FUNCTION LOAD` is `ADMIN_ONLY` in the catalog and belongs
|
||||
to the admin plane, so `RedisFunctionOperations` has no method that introduces server-side code.
|
||||
`RegisteredRedisFunction` carries the library's semantic version because a library replaced under
|
||||
the same name changes behaviour with no signal at the call site. A function declared read-only is
|
||||
issued as `FCALL_RO`, which lets the server refuse a wrong declaration — worth more than the replica
|
||||
routing it also buys.
|
||||
|
||||
**Task 20 is deliberately not half-done.** `WATCH`/`MULTI`/`EXEC` is implementable against Lettuce —
|
||||
after `MULTI` the command futures complete when `EXEC` runs — but proving it needs a fixture that
|
||||
models that deferral. The current `InMemoryRedisCommandGateway` completes every future eagerly, so a
|
||||
transaction written against it would apply its writes *before* the `WATCH` conflict was detected: the
|
||||
fixture would report a correct-looking conflict while the effects had already landed. A fake that
|
||||
lies about atomicity is worse than no fake, so the transaction work is deferred until the fixture
|
||||
grows a deferral model (or the real-server lane from Task 26 exists), rather than being landed
|
||||
against a fixture that cannot falsify it.
|
||||
|
||||
## 16. Task 22 — the escape hatch, and why it is not an escape
|
||||
|
||||
The raw gateway exists because a few commands have no typed form worth building, not because
|
||||
arbitrary command execution is acceptable. Everything about its shape follows from that.
|
||||
|
||||
**Two independent gates, neither decided at request time.** A command must be classified
|
||||
`RAW_ONLY` in `redis-command-policy.yml` — the organization's decision about which commands may ever
|
||||
leave through this door — *and* the deployment must have registered an `ApprovedRawCommand` for it
|
||||
in `RawCommandApprovals`. Neither alone is enough. The approval carries the argument, request, and
|
||||
reply ceilings and the timeout, so widening what may be sent is a deployment change, not a call-site
|
||||
one.
|
||||
|
||||
**The token is bound to its registry.** `RawCommandApprovals.issue` is the only source, and
|
||||
`verify` refuses a token from a different registry instance, a token issued for another policy, and
|
||||
an approval that is not byte-for-byte the registered one. That last check is the one that matters:
|
||||
without it a caller could present a widened copy of a real approval and keep the real policy id.
|
||||
|
||||
**Keys are parsed back, not taken on trust.** Arguments reach the gateway as opaque bytes, so the
|
||||
catalog's key specification locates the key positions and `RedisOperationContext.parseKey` — the
|
||||
same strict parse `SCAN` uses — turns each one back into a `QualifiedRedisKey`. A key outside the
|
||||
bound namespace or one that does not follow the key grammar is refused before anything is sent. A
|
||||
`movable` key specification cannot be checked without asking the server with `COMMAND
|
||||
GETKEYSANDFLAGS`, so it is refused at registration time; `SORT` and `SORT_RO` are therefore
|
||||
classified `RAW_ONLY` but not approvable until that lookup exists.
|
||||
|
||||
**Every RAW_ONLY command now names a permit policy.** The guard's rule is that an R2 command always
|
||||
states the policy that authorised it. The raw path used to be the one place that rule did not hold,
|
||||
so `raw-command` was added to the three `RAW_ONLY` entries and the gateway presents the SDK permit
|
||||
for it. The approval registry still decides *which* commands a deployment may send; the permit is
|
||||
what keeps the guard's invariant true on this path too.
|
||||
|
||||
**Everything else was already built.** Reachability, minimum version, risk refusal, and the timeout
|
||||
profile come from the catalog; namespace and same-slot from the guard; the audit record from the
|
||||
executor's observation, which carries the command family and latency and never a key or a value.
|
||||
The one new seam method, `sendApprovedRaw`, takes a `CommandId` rather than a string — by the time
|
||||
it is reached the identity has already been validated, classified, and matched to an approval.
|
||||
|
||||
## 17. Task 23 — the admin plane is defined by what it cannot do
|
||||
|
||||
Design section 14.2 lists what the admin plane must never reach. None of it is enforced by
|
||||
`RedisAdminOperations` omitting a method — omission is not enforcement, because the next person to
|
||||
add one would not notice. `FLUSHDB`, `FLUSHALL`, `SHUTDOWN`, `DEBUG`, `CONFIG SET`, `CONFIG REWRITE`,
|
||||
`CLIENT KILL`, `ACL SETUSER`, `ACL DELUSER`, `SLOWLOG RESET`, `LATENCY RESET`, `SCRIPT FLUSH`,
|
||||
`FUNCTION FLUSH`, and `MODULE UNLOAD` are all `BLOCKED` in the catalog, which means no path in the
|
||||
SDK can send them, and a test asserts that list rather than trusting it.
|
||||
|
||||
**Every diagnostic is checked against the catalog before it is built.** Not classified
|
||||
`ADMIN_ONLY`, or not read-only, and it is refused. That check is what stops a future addition to
|
||||
this class from quietly becoming a write.
|
||||
|
||||
**Replies are projected, not forwarded.** A slow log entry carries the command family and drops the
|
||||
arguments; a client entry carries id, age, idle, and last command and drops the peer address and the
|
||||
connection name. Both are read by an operator and end up in dashboards and tickets, and the dropped
|
||||
fields are exactly the caller and tenant identity that must not travel that way. The command family
|
||||
is enough to find a call site; an address is not needed to find a leaking pool.
|
||||
|
||||
**A key is still a key.** `MEMORY USAGE` takes a `QualifiedRedisKey` and goes through the guard, so
|
||||
an admin diagnostic cannot read a key outside the bound namespace. An absent key reports {@code -1},
|
||||
not zero, because "this key uses no memory" and "this key does not exist" are different answers.
|
||||
|
||||
**Separation is structural, not documentary.** The plane takes its own gateway, bound to the admin
|
||||
account's own connection, the same way the blocking operations take theirs. What that cannot enforce
|
||||
is that the deployment actually configured a separate ACL account — which is precisely why the
|
||||
dangerous commands are blocked catalog-wide rather than left to the credentials to prevent.
|
||||
|
||||
## 18. Tasks 24–25 — four extensions, one seam, and the checks the guard cannot do
|
||||
|
||||
All four extension families share `ExtensionCommandRunner`, so every extension command declares its
|
||||
key and is namespace- and slot-checked exactly like a classic one. Sharing the runner is also what
|
||||
stops them drifting apart on the parts that matter.
|
||||
|
||||
**The probe is the authority, the version is a pre-filter.** A managed Redis 8 with no module loaded
|
||||
reports the version and not the commands, so each bean is created through `ifSupported(...)` and a
|
||||
deployment without the module simply has no instance. Catalog minimums are the second gate, not the
|
||||
first.
|
||||
|
||||
**Bounds are in the types, not in a caller's discipline.** A `JsonPath` is validated against a
|
||||
narrow grammar — roots, members, indices, recursive descent — so a path assembled from request data
|
||||
cannot become `$` and replace a whole document. A `TimeSeriesSample` series is created with a
|
||||
retention or not at all; unlike a stream there is no per-append trim to fall back on. A `SearchQuery`
|
||||
carries its offset, page size, and timeout, so "read the whole index" cannot be written. Every
|
||||
probabilistic structure is reserved with an explicit error rate and capacity, because one created
|
||||
implicitly by its first write gets server defaults and saturates into answering "probably present"
|
||||
for everything.
|
||||
|
||||
**The interfaces say the answers are approximate.** `probablyContains`, `estimateCount`,
|
||||
`estimateQuantile` — a false-positive rate does not become a correctness bug because someone read a
|
||||
method called `contains`.
|
||||
|
||||
**Search is the one place the guard cannot help.** An `FT` command addresses an index, and an index
|
||||
is not a key, so there is no key on the request to namespace-check. The index name is therefore a
|
||||
validated type rendered with the process's namespace prefix by the operations class, and the key
|
||||
prefix an index covers is rendered the same way. An index can only be created over — and queried
|
||||
against — documents this process owns, and that rule lives in one method rather than in a review
|
||||
checklist. `FT.DROPINDEX` is `BLOCKED` for the whole SDK: dropping an index is a destructive
|
||||
operational action, and an accidental one is indistinguishable from a search that suddenly returns
|
||||
nothing.
|
||||
|
||||
**What is still owed:** evidence against real modules. Nothing here proves how RedisJSON, the query
|
||||
engine, Time Series, or the probabilistic structures actually reply — the fixture answers with what
|
||||
the design says they answer. That is Task 26's lane.
|
||||
|
||||
## 19. The "dead capability property blocks" item was wrong
|
||||
|
||||
Earlier notes in this delivery listed `app-bootstrap/src/main/resources/application.yml`, `src/.env`,
|
||||
and `docs/registries/env-keys.yaml` as carrying dead property blocks for five removed capabilities
|
||||
(cache, session, idempotency, lease, rate-limit), to be deleted together because `verifyEnvKeys` is
|
||||
fail-closed.
|
||||
|
||||
That is not true for at least three of them. `ca-skeleton.capabilities.rate-limit.provider`,
|
||||
`.idempotency.provider`, and `.lease.provider` are read at startup by
|
||||
`dev.caskeleton.bootstrap.runtime.SecretSourceValidator`, which refuses to start when a provider is
|
||||
selected without its HMAC secret, and `SecretSourceValidatorTest` covers all three. Deleting those
|
||||
blocks would remove a live startup check and break the test.
|
||||
|
||||
`app.rate-limit.*` is a separate, also live tree bound by `EdgeRateLimitTransportSettings` in
|
||||
`adapter:inbound:web`; it is not the same property as the capability selector above and the two must
|
||||
not be conflated.
|
||||
|
||||
The `ca-skeleton.capabilities.cache.canonical.*` and `ca-skeleton.security.redis-session.*` blocks
|
||||
have no binder that a source search finds, so they may genuinely be residue — but "no binder found"
|
||||
is not the same as "unused", and removing keys from a fail-closed three-file invariant on that basis
|
||||
is not a change worth making without auditing each key's consumers. No cleanup was performed.
|
||||
|
||||
## 20. Tasks 26–27 — the harness landed, the evidence did not
|
||||
|
||||
I previously described these two as blocked on a real server. That was wrong and worth correcting:
|
||||
the *evidence* needs servers, but the harness, the ACL accounts, the docs gates, and the CI wiring
|
||||
are all files, and they are now in the repository.
|
||||
|
||||
**What landed.**
|
||||
|
||||
- `infra/redis-sdk/{standalone,sentinel,cluster}/compose.yml` — three lanes, version-parameterised so
|
||||
one file serves every row of the support matrix. Sentinel runs three sentinels because a
|
||||
two-sentinel quorum cannot survive losing one, and a failover test that cannot lose a sentinel is
|
||||
not testing failover. Cluster runs six nodes so a promotion can be forced without losing a shard,
|
||||
and waits for slot assignment before tests start.
|
||||
- `infra/redis-sdk/acl/*.acl` — one account per `CommandAccess` level, each deliberately narrower
|
||||
than the SDK's own rules. The account is the last boundary and a permit never widens it, so a
|
||||
mistake in the SDK is still refused by the server.
|
||||
- `redisTopologyTest`, a Gradle lane tagged `redis-topology` and excluded from the default unit task.
|
||||
It **fails closed**: selecting it without host, port, and mode is a `GradleException`, and
|
||||
`RedisTopologyEndpoint` refuses to default to `localhost:6379`. A topology test that silently
|
||||
passes because it never connected is worse than not having one.
|
||||
- `docs/redis/support-matrix.md`, which `RedisSupportMatrixTest` parses. A package or a capability
|
||||
that is not listed fails the build, so stating the support level is part of shipping a module
|
||||
rather than a follow-up someone remembers. The certified-version table says "lane declared, not
|
||||
run" for all three topologies, and the test asserts that string — a certified version cannot be
|
||||
claimed from a lane that has never produced evidence.
|
||||
- `docs/redis/command-policy.md`, `operations.md`, `upgrade-guide.md`. The upgrade guide states why
|
||||
each check exists, not just that it is required: an unclassified command is refused, but a command
|
||||
whose risk changed upstream and is still classified R1 here is not; a rollback that leaves a
|
||||
process holding stale script digests produces `NOSCRIPT` on every scripted call.
|
||||
- `.github/workflows/redis-sdk-topology.yml`, manual-dispatch only, plus two new entries in
|
||||
`.github/ci-gate-matrix.yml` — the support matrix as a release-blocking contract test, and the
|
||||
topology evidence as explicitly `delegated-pending`. The gate count moved from 27 to 29.
|
||||
|
||||
**What did not land: the evidence.** No assertion in `RedisTopologyContractTest` yet exercises a
|
||||
promotion, a resharding, an ACL denial, or the guardrail datasets from the plan (1 MiB string,
|
||||
hundred-thousand-element collections, a million-entry trimmed stream, a five-hundred-command
|
||||
pipeline). Writing those assertions against a lane that has never been started would produce tests
|
||||
whose first run is also their first review, so the lane is fail-closed and the support matrix says
|
||||
plainly that nothing is certified. That is the honest state, and the harness is what makes closing
|
||||
it a bounded piece of work rather than a project.
|
||||
|
||||
## 21. The standalone lane ran, and it found five defects
|
||||
|
||||
The lane in `infra/redis-sdk/standalone` was started against Redis 7.4 and
|
||||
`RedisTopologyContractTest` now asserts, for every account in `infra/redis-sdk/acl`, that the
|
||||
`CommandAccess` level grants exactly what the command policy catalog says it may issue. Seven tests
|
||||
pass. Getting there required fixing five things that reading the files would never have surfaced:
|
||||
|
||||
1. **The ACL files did not load at all.** A Redis `aclfile` accepts nothing but complete `user`
|
||||
lines — no comments, no line continuations — and the server refused to start. The rationale moved
|
||||
to `infra/redis-sdk/acl/README.md`, and the four accounts are concatenated into
|
||||
`all-accounts.acl` because Redis takes one `aclfile`.
|
||||
2. **The advanced account granted `SMEMBERS` and `SORT`.** Both are `RAW_ONLY`, so they belong to the
|
||||
raw gateway account alone. This is the defect worth caring about: the ACL account is the last
|
||||
enforcement boundary and a permit never widens it, so an account wider than the catalog silently
|
||||
removes the second control the whole raw-gateway design rests on.
|
||||
3. **The ordinary account granted `SORT_RO`,** for the same reason.
|
||||
4. **The ordinary account could not run `PUBLISH`, `SUBSCRIBE`, or `PING`,** all classified `TYPED`.
|
||||
5. **The ordinary account could not run `MULTI`, `EXEC`, `UNWATCH`, or `DISCARD`,** also `TYPED`.
|
||||
|
||||
6. **The admin account was missing twelve read-only diagnostics** the catalog exposes: the `OBJECT`,
|
||||
`PUBSUB`, and `XINFO` subcommands, `FUNCTION LIST`/`STATS`, and `CLUSTER KEYSLOT`. Closing this
|
||||
also forced a decision: `FUNCTION LOAD` is `ADMIN_ONLY` but not read-only, and granting it to an
|
||||
account named `admin-readonly` would make the name a lie. Loading a library is a deployment
|
||||
action with its own credentials, so the assertion covers read-only `ADMIN_ONLY` commands only.
|
||||
|
||||
There was also a defect in the test itself, which is worth recording because it is the failure mode
|
||||
this kind of test usually dies of: `ACL DRYRUN` checks arity *before* permission, so probing a
|
||||
command with the wrong number of arguments answers "wrong number of arguments" for an account that
|
||||
would have been refused anyway. Reading that as a grant makes the test pass while the account is
|
||||
wrong. The probe now walks argument counts until the server actually answers the permission
|
||||
question. A second one followed it: a command the server does not carry answers "not found", and
|
||||
skipping that without checking the catalog's minimum version is how a real ACL gap hides behind a
|
||||
module that happens not to be installed. An absent command is now only tolerated when the catalog
|
||||
already says the server is too old for it.
|
||||
|
||||
`docs/redis/support-matrix.md` records standalone 7.4 as "ACL contract verified"; Sentinel and
|
||||
Cluster remain "lane declared, not run", and `RedisSupportMatrixTest` still asserts that string.
|
||||
|
||||
**Still owed on this task:** the guardrail datasets (1 MiB string, hundred-thousand-element
|
||||
collections, a million-entry trimmed stream, a five-hundred-command pipeline) and the fault
|
||||
injection — promotion on the Sentinel lane, resharding on the Cluster lane. Those are the assertions
|
||||
`ExecutionCertainty` and `RedisSlotCalculator` were built to be checked against.
|
||||
|
||||
## 22. The guardrail run found the first real SDK defect
|
||||
|
||||
`LiveRedisGuardrailTest` is the first thing that puts `LettuceRedisCommandGateway` under the SDK's
|
||||
own contracts against a live server. Everything before it ran against
|
||||
`InMemoryRedisCommandGateway`, which is a deterministic stand-in and answers what the design says it
|
||||
should — so an encoding or budgeting mistake could not show up there by construction.
|
||||
|
||||
It found one immediately, and it is a good example of the class of bug a fake cannot catch:
|
||||
|
||||
**The cursor-scan reply budget was sized to the requested `COUNT`.** Redis treats `COUNT` as a hint,
|
||||
not a limit: it walks whole hash buckets and listpack entries and returns what it found. A real
|
||||
`HSCAN` asked for 500 came back with 501, and the SDK rejected a perfectly correct reply — a refusal
|
||||
the caller can neither act on nor avoid. `RedisOperationContext.scanBudget` now accepts the
|
||||
configured scan ceiling plus a fixed overshoot allowance, which is still a bound: a server returning
|
||||
an order of magnitude more than it was asked for is refused. All four scan sites (key, hash, set,
|
||||
sorted set) use it.
|
||||
|
||||
The rest of the datasets passed unchanged: the 1 MiB value ceiling holds and one byte over never
|
||||
leaves the process; a hundred-thousand-field hash refuses `HGETALL` and is only reachable by cursor;
|
||||
a stream trimmed to 1,000 stays trimmed while twenty thousand entries are appended; a
|
||||
five-hundred-command batch reports every item positionally.
|
||||
|
||||
**Still owed:** Sentinel promotion and Cluster resharding. Those need their own lanes started, and
|
||||
they are where `ExecutionCertainty` and `RedisSlotCalculator` finally get checked against reality.
|
||||
|
||||
## 23. The Sentinel and Cluster lanes ran, and the worst defect was not in the code
|
||||
|
||||
Both remaining lanes now produce evidence. `docs/redis/support-matrix.md` records which test
|
||||
produced which, and `RedisSupportMatrixTest` no longer asserts the literal string
|
||||
`"lane declared, not run"` — that gate worked only until the lanes ran, and a gate that has to be
|
||||
deleted the moment it binds was never a gate. It now requires every evidence claim to name a test
|
||||
class that exists in the source tree, which is a rule that survives the lanes running.
|
||||
|
||||
### The harness had to be fixed before it could produce anything
|
||||
|
||||
Neither compose file could have worked. Both published no ports, and more importantly both would
|
||||
have advertised container-internal addresses: Sentinel answers `get-master-addr-by-name` with the
|
||||
address it monitors and the client dials that itself, and a cluster client reads `CLUSTER SHARDS`
|
||||
and connects to every node it names. On a bridge network a host client resolves a topology it cannot
|
||||
reach. Both lanes now use host networking with fixed ports, which is the only arrangement where the
|
||||
address the topology advertises is the address the client can use.
|
||||
|
||||
Three smaller harness defects went with it: the endpoint record assumed the declared address was a
|
||||
data node (on the Sentinel lane it is a sentinel, so ACL assertions were being asked of the
|
||||
sentinel's own accounts); the CI workflow passed `6379` for all three lanes; and `redisTopologyTest`
|
||||
was cacheable, so Gradle reported a previous run's verdict as the current one against a lane that
|
||||
had since been restarted and promoted. Lane selection is now derived from the declared mode
|
||||
(`redis-topology & lane-<mode>`) so a promotion test is never selected on a standalone lane and
|
||||
never silently skipped either.
|
||||
|
||||
### The finding: a superseded primary keeps acknowledging writes
|
||||
|
||||
This is the most serious thing this delivery has surfaced, and none of it is in the SDK's code.
|
||||
|
||||
Sentinel promoted the replica at `05:56:12.503` and did not demote the old primary until
|
||||
`05:56:23.529`. For those eleven seconds the client stayed connected to a primary that had already
|
||||
been replaced, wrote, and was told `+OK` **2,086 times**. Every one of those writes was discarded
|
||||
when the old primary resynced from the new one — the server's own log says so:
|
||||
`Partial resynchronization not accepted: Requested offset for second ID was 9897663, but I can reply
|
||||
up to 9731839`. Exactly **one** command failed in the whole run.
|
||||
|
||||
There is no client-side signal for this. The server answered, so the driver recorded a success, the
|
||||
SDK recorded `CONFIRMED_SUCCESS`, and the caller was told the write landed. A second run made the
|
||||
point harder: sixteen thousand attempts, **zero** exceptions, 2,086 acknowledged writes gone.
|
||||
|
||||
`SentinelFailoverObserver` counts *ambiguous* writes and its documentation called those "the ones an
|
||||
operator has to reconcile". That was wrong by three orders of magnitude — the writes that actually
|
||||
needed reconciling were the confirmed ones, and no counter on the client can be made to include
|
||||
them. The class now says so instead of implying it measures something it cannot.
|
||||
|
||||
What closes the window is server-side. Re-running the identical promotion with
|
||||
`min-replicas-to-write 1` and `min-replicas-max-lag 1` cut acknowledged-and-discarded writes from
|
||||
**2,086 to 1**: the orphaned primary refused 2,020 writes with `NOREPLICAS`, which the SDK already
|
||||
translates to a definite, non-ambiguous failure. Both settings are in the lane, and
|
||||
`acknowledgedWriteLossIsBounded` ties the tolerated loss to the configured lag window rather than to
|
||||
a magic number.
|
||||
|
||||
### The assertion immediately caught a second version of the same mistake
|
||||
|
||||
The first run with the setting passed. The second failed, with 2,099 lost writes — because the
|
||||
setting had been written into the `primary` service only. These two nodes swap roles on every
|
||||
failover, so a guardrail applied to whichever one happens to start as primary stops applying the
|
||||
moment the lane does the thing it exists to do. Both data nodes now take their whole configuration
|
||||
from one definition, which makes the asymmetry impossible to reintroduce. Three consecutive
|
||||
promotions in both directions since: 0, 0, and 1 acknowledged write lost.
|
||||
|
||||
### One real translator defect
|
||||
|
||||
The promotion closed the channel under an in-flight `RPUSH` and Lettuce raised a bare
|
||||
`RedisException`, which matched no branch of `LettuceExceptionTranslator` and fell through to a
|
||||
generic failure reported with `ambiguous=false` — that is, as a write that *definitely did not run*.
|
||||
Nothing about an unrecognised failure supports that claim, and a caller who believes it retries a
|
||||
non-idempotent write. The fallback now treats an unclassified write failure as ambiguous, which is
|
||||
the safe direction, and two unit tests pin both branches.
|
||||
|
||||
### Cluster: the arithmetic holds
|
||||
|
||||
`LiveRedisClusterTest` checked `RedisSlotCalculator` against `CLUSTER KEYSLOT` over a corpus built
|
||||
from the brace rules a hand-written implementation gets wrong — `{}`, `a{}b`, `foo{}{bar}`,
|
||||
`foo{{bar}}zap`, `foo{bar}{zap}`, `{`, `}`, `}{`, an unclosed brace, the empty key, and non-ASCII
|
||||
keys. No disagreements, and the result was reproduced independently against the server outside the
|
||||
test. The rendered-key invariant holds too: the slot the SDK computes from a tag alone equals the
|
||||
slot the server computes from the whole rendered key, which is what makes the two-step design sound.
|
||||
|
||||
Cross-slot refusal was checked in both directions, because a guard stricter than the cluster costs
|
||||
availability for nothing and a looser one sends requests that cannot succeed; the pair the guard
|
||||
refuses is the pair the server answers `CROSSSLOT` for. Redirects were observed rather than assumed:
|
||||
a `MOVED` names the slot the client computed, and a slot put into a real `MIGRATING`/`IMPORTING`
|
||||
state answers `ASK` for an absent key and `TRYAGAIN` for a multi-key request that straddles the
|
||||
migration. The lane restores the slot to `STABLE`, so a run leaves the cluster as it found it.
|
||||
|
||||
Nothing in `sdk.cluster` needed changing. That is worth recording as an outcome, not treated as the
|
||||
test having nothing to say: the calculator is the one piece of this SDK that silently degrades into
|
||||
wrong refusals and wrong admissions if it is off by one, and it is now checked rather than assumed.
|
||||
|
||||
### Where this leaves the task
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Unit | 288 tests, 0 failures |
|
||||
| Standalone lane | 14 tests, 0 failures |
|
||||
| Sentinel lane | 8 tests, 0 failures, three promotions in both directions |
|
||||
| Cluster lane | 14 tests, 0 failures |
|
||||
| `check` + architecture/env/public-path | green for `adapter:outbound:cache-redis` |
|
||||
| `verify-gate-matrix.sh` | 29 gates, 27 verified, 2 delegated-pending, OK |
|
||||
|
||||
Defects found and fixed across the whole evidence effort: six in the ACL accounts, two in the ACL
|
||||
test itself, one in the scan budget, four in the topology harness, one in the Sentinel lane's
|
||||
configuration, one in the exception translator, and one documentation claim that was wrong by three
|
||||
orders of magnitude.
|
||||
|
||||
`:app-bootstrap:test --tests '*CleanArchitectureTest'` passes. It briefly did not, on
|
||||
`NO_UUID_RANDOM_IN_CONTROLLER` in `application.fileserver.cleanup.CleanupItem` — untracked
|
||||
in-progress work from a different feature that was being edited while this evidence ran. The
|
||||
identifier factories have since moved to `CleanupRequest` and no direct `UUID.randomUUID` or
|
||||
`UuidCreator` call remains in `application-core`, so the rule is satisfied by the current sources
|
||||
rather than waived.
|
||||
|
||||
**Task 20 is the only implementation task left.**
|
||||
|
||||
## 24. Task 20 — the fixture had to learn to defer before the contract meant anything
|
||||
|
||||
Task 20 was deferred back at section 15 for a reason that turned out to be the whole task: the
|
||||
in-memory fixture executes every command the moment it is called, so a transaction written against
|
||||
it would have passed while proving the opposite of what it claimed. The writes would already have
|
||||
happened before the commit, and a watch conflict would have had nothing left to discard.
|
||||
|
||||
The controller chose the full option — every command available inside the window, and the fixture
|
||||
reworked to match — over a narrow hand-picked subset.
|
||||
|
||||
### Deferral is one property, not a hundred and eleven
|
||||
|
||||
`RedisCommandGateway` has 111 methods and every one of them returns a `CompletionStage`. That is not
|
||||
incidental: deferral is a property of the *connection*, so it can be implemented once rather than
|
||||
per command.
|
||||
|
||||
On the production side it costs nothing at all. Lettuce already defers everything issued after
|
||||
`MULTI` and completes those futures from the `EXEC` reply, so `LettuceRedisCommandGateway` needed no
|
||||
change to any existing method — only the five new seam methods (`watch`, `unwatch`,
|
||||
`beginTransaction`, `commitTransaction`, `discardTransaction`). `commitTransaction` returns a
|
||||
boolean rather than a list of results, because the per-command stages resolve themselves and the
|
||||
only thing `EXEC` alone can say is whether it ran.
|
||||
|
||||
On the test side, `DeferringRedisCommandGateway` is a `java.lang.reflect.Proxy` that records an
|
||||
invocation, hands back an unfinished future, and replays it against the fixture at commit — which is
|
||||
exactly when Redis runs it. The 1,996-line fixture was not edited for it. The consequence that
|
||||
matters: a command added to the seam later cannot forget to be transactional.
|
||||
|
||||
The one part that does need the data is the watch check, so that lives in the fixture. It hashes the
|
||||
watched key's current contents rather than incrementing a counter at each of the sixteen mutation
|
||||
sites — a counter is something a seventeenth mutation can silently fail to update, and a hash is not.
|
||||
|
||||
### What the contract refuses to let a caller do
|
||||
|
||||
`QueuedReply.value()` throws before the commit. The alternative — returning `null` or a zero for a
|
||||
command the server has only answered `+QUEUED` to — is the trap the type exists to remove.
|
||||
|
||||
`TransactionResult` reports exactly two outcomes, "executed" and "a watched key changed so nothing
|
||||
ran", and neither is a rollback. Redis has none: a command that fails at runtime inside `EXEC` does
|
||||
not undo the ones around it, and the proxy reproduces that faithfully by failing one future and
|
||||
leaving the rest alone.
|
||||
|
||||
`RedisTransactionQueue` is write-only, which is a contract rather than an unfinished surface. A read
|
||||
inside the window cannot be branched on — its reply does not exist until every command has already
|
||||
been chosen — so accepting one would only offer a way to write code that looks conditional and is
|
||||
not. Reads a transaction depends on belong before it, under `WATCH`.
|
||||
|
||||
Queued commands go through `QueueingRedisCommandExecutor`, which is `SyncRedisCommandExecutor` with
|
||||
the wait removed and *nothing else* changed. The same `CommandPolicyGuard` admits them, so namespace,
|
||||
slot, permit, and budget rules hold identically: a transaction is not a way around the guard, and a
|
||||
test asserts that a foreign-namespace key is refused inside a window exactly as it is outside one.
|
||||
|
||||
### Three defects the tests found
|
||||
|
||||
1. **A callback returning nothing crashed the transaction.** `Optional.of` on a null body result
|
||||
threw an NPE after a perfectly successful commit. A transaction with no interesting return value
|
||||
is entirely normal, so the result now carries an empty value for it and the invariant only forbids
|
||||
a value on a transaction that did not execute.
|
||||
2. **`RedisTransactionQueue.delete` could never succeed.** `DEL` is R2 in the catalog because it
|
||||
accepts any number of keys, so it needs a permit and a budget even when a transaction queues
|
||||
exactly one. The queue presents the SDK's own permit rather than making every caller thread one
|
||||
through for a single-key delete.
|
||||
3. **The first conflict test was contending with itself.** It wrote the watched key through the same
|
||||
gateway — that is, from inside the very window it was supposed to be contending with — so the
|
||||
write was queued rather than applied and the transaction timed out instead of conflicting. A
|
||||
competing writer has to come from another connection, and the test now has one. This is the kind
|
||||
of mistake that would have produced a green test if the fixture had not been deferring.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Unit | 296 tests, 0 failures |
|
||||
| `check` | green for `adapter:outbound:cache-redis` |
|
||||
|
||||
**Every implementation task in the plan is now done.**
|
||||
@@ -0,0 +1,160 @@
|
||||
# Release Hygiene Refactoring Design
|
||||
|
||||
**Date:** 2026-08-01
|
||||
**Status:** approved by the user's instruction to apply the preceding review
|
||||
**Scope:** release-blocking architecture test, Gradle wrapper supply-chain integrity, Docker build configuration inputs, SpotBugs analysis completeness, and the observed Gradle 10 deprecation
|
||||
|
||||
## Context
|
||||
|
||||
The repository-wide review found that the 19-leaf Clean Architecture dependency model is healthy,
|
||||
but the release surface is not green:
|
||||
|
||||
- `:app-bootstrap:sampleOffTest` fails because a whole-composition Object Storage ArchUnit rule is
|
||||
evaluated on the intentionally sample-free classpath with `allowEmptyShould(false)`.
|
||||
- the two Dockerfiles run Gradle before copying configuration-time registry inputs, while the root
|
||||
build also requires a Git checkout during configuration even though the Docker context excludes
|
||||
`.git`;
|
||||
- `gradle-wrapper.properties` selects Gradle 9.0.0 while the checked-in wrapper JAR is from another
|
||||
official Gradle release, and the distribution checksum is absent;
|
||||
- clean SpotBugs analysis reports missing Spring Session, Micrometer Context Propagation, and
|
||||
protobuf classes;
|
||||
- a root task calls `Task.project` during execution, which is deprecated and scheduled to fail in
|
||||
Gradle 10.
|
||||
|
||||
This design deliberately closes those release-hygiene defects before changing idempotency, outbox,
|
||||
security, or sample data behavior. Each later subsystem gets a separate design and plan so that a
|
||||
reviewer can accept or revert it independently.
|
||||
|
||||
## Considered Approaches
|
||||
|
||||
### Approach A: weaken the existing global gates
|
||||
|
||||
Set ArchUnit rules to allow empty matches, ignore SpotBugs missing-class messages, and make Docker
|
||||
configuration registries optional. This is the smallest diff, but it makes the architecture and
|
||||
static-analysis gates less trustworthy. Rejected.
|
||||
|
||||
### Approach B: patch each symptom in place
|
||||
|
||||
Condition the ArchUnit rule on a sample flag, copy only the two currently missing registry files,
|
||||
and add the three currently missing SpotBugs JARs manually. This would pass today's cases but would
|
||||
recur whenever another leaf, registry, source set, or dependency is added. Rejected because it
|
||||
duplicates ownership knowledge.
|
||||
|
||||
### Approach C: align ownership and derive inputs from the owning model
|
||||
|
||||
Move the leaf-specific architecture rule to the Object Storage leaf, keep root tests responsible
|
||||
for cross-leaf registration, treat `config/**` as a declared Docker configuration input, move Git
|
||||
evidence checks to the evidence task execution phase, align the wrapper artifacts to one version,
|
||||
and derive SpotBugs auxiliary inputs from each analyzed source set's runtime classpath. Selected.
|
||||
|
||||
## Architecture Test Ownership
|
||||
|
||||
`adapter-outbound-objectstorage` owns rules about the public types of its production adapter methods.
|
||||
The rule moves out of `app-bootstrap` and runs in the Object Storage module's normal test suite.
|
||||
It remains strict: the Object Storage module must contain matching production classes and the rule
|
||||
must not globally allow an empty `should` clause.
|
||||
|
||||
`app-bootstrap` continues to own cross-module rules. Its sample-off suite verifies that production
|
||||
composition works without `sample-portfolio`; it does not require sample-only leaves to be present.
|
||||
The existing module registry and dependency verification remain the SSOT for leaf coverage.
|
||||
|
||||
## Gradle Wrapper Integrity
|
||||
|
||||
Gradle 9.0.0 remains the selected version for this refactoring. The wrapper scripts, properties, and
|
||||
JAR are regenerated from Gradle 9.0.0 in a trusted environment. The official 9.0.0 binary
|
||||
distribution SHA-256 is recorded as:
|
||||
|
||||
```text
|
||||
8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b
|
||||
```
|
||||
|
||||
The wrapper properties are one exact ordered eight-line byte contract, preventing Java Properties
|
||||
duplicate-key, separator, escape, and continuation semantics from overriding the reviewed values.
|
||||
The complete six-file workflow path set and every workflow's SHA-256 are embedded as a reviewed
|
||||
byte lock in the verifier. This is the primary completeness boundary: YAML has aliases, encoded
|
||||
keys, duplicate-key overrides, custom shells, and other equivalent representations that a partial
|
||||
Bash parser cannot safely model. Any workflow addition, removal, rename, symlink replacement, or
|
||||
byte change fails until the complete workflow diff is intentionally reviewed and the sorted lock
|
||||
is refreshed in the same change.
|
||||
|
||||
The restricted block-style workflow grammar remains defense in depth and supplies actionable
|
||||
diagnostics for ordinary drift. Every Gradle-running job uses an unconditional validation step
|
||||
with a stable ID and the action pinned by commit SHA. Checkout and validation precede every Gradle
|
||||
invocation, not only the first; a cleanup/sanitizer step that intentionally uses `always()` also
|
||||
requires the validation step's successful outcome. This is consistent with the repository's
|
||||
existing pinned `actions/setup-java` policy and prevents wrapper failure from being bypassed by
|
||||
step conditions.
|
||||
|
||||
## Docker Configuration Contract
|
||||
|
||||
Both Docker build dependency-cache stages preserve the repository layout with `WORKDIR /build/src`
|
||||
and copy the complete `config/**` tree before invoking Gradle. The parent `/build` is therefore the
|
||||
repository root expected by registry `source_path: src/**` entries. This is intentional: Gradle
|
||||
configuration registries and their repository-relative path base are build inputs, while the
|
||||
registry's exact internal file list may evolve.
|
||||
|
||||
Git revision validation no longer runs unconditionally while the build script is being configured.
|
||||
A root-owned resolver is invoked once from each root evidence action or leaf evidence test's
|
||||
root-suite completion action; eager scalar evidence properties are removed. Only evidence-producing
|
||||
tasks resolve the checkout revision during their execution. Docker builds provide
|
||||
`-PgitRevision=<40 lowercase hex>` and do not copy `.git` into the image context.
|
||||
|
||||
The boot JAR path is obtained from Gradle's archive output contract rather than selecting the first
|
||||
filesystem match. The final images retain the existing digest-pinned base image, non-root user,
|
||||
read-only root filesystem, and JRE-only runtime.
|
||||
|
||||
## SpotBugs and Gradle 10 Compatibility
|
||||
|
||||
Every SpotBugs task analyzes a named source set and receives that source set's runtime classpath as
|
||||
its auxiliary analysis classpath, excluding its own compiled output. Custom test source sets are
|
||||
covered by the same rule. No production dependency scope is widened merely to silence SpotBugs.
|
||||
|
||||
Missing-analysis-class output is treated as a gate failure. The clean gate must produce zero
|
||||
`classes needed for analysis were missing` messages.
|
||||
|
||||
The observed Gradle 10 deprecation is removed by capturing the application-core project during
|
||||
configuration instead of calling `Task.project` from the task action. The dependency-purity gate
|
||||
still traverses that project's configurations during execution, so it explicitly opts out of the
|
||||
configuration cache rather than claiming serializable declared inputs it does not have.
|
||||
|
||||
## Error Handling and Failure Semantics
|
||||
|
||||
- sample-off fails only for a real production composition or architecture violation;
|
||||
- an empty Object Storage rule in its owning module is a test failure;
|
||||
- a wrapper JAR or distribution checksum mismatch fails before Gradle build logic executes in CI;
|
||||
- missing Docker configuration input fails with a named build-contract test rather than an opaque
|
||||
settings error;
|
||||
- invalid or absent `gitRevision` fails only an evidence task that requires it;
|
||||
- SpotBugs missing classes fail static analysis instead of producing a successful partial report.
|
||||
|
||||
## Verification Design
|
||||
|
||||
The implementation follows red-green-refactor. Each behavior has a regression test or executable
|
||||
contract that fails before the production/configuration change:
|
||||
|
||||
1. reproduce `sampleOffTest` failure, then add an owner-module architecture test and remove the
|
||||
misplaced global rule;
|
||||
2. add wrapper property and workflow contract assertions before regenerating the wrapper;
|
||||
3. extend Docker contract tests so a cache-stage Gradle configuration fixture requires `config/**`
|
||||
and accepts an attested `gitRevision` without `.git`;
|
||||
4. add Gradle build-contract coverage for source-set-derived SpotBugs auxiliary classpaths, the
|
||||
removed execution-time `Task.project` access, and the explicit configuration-cache opt-out;
|
||||
5. run focused gates, then the clean repository-wide gate and gate-matrix script.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no dependency version upgrade beyond aligning the wrapper to the already selected Gradle 9.0.0;
|
||||
- no business/domain behavior changes;
|
||||
- no idempotency, outbox, Poster publication, security, DTO, or database migration changes;
|
||||
- no broad extraction of the 3,768-line root build script in this phase;
|
||||
- no agent-created branch, stage, commit, amend, or push.
|
||||
|
||||
## Decision Summary
|
||||
|
||||
- Object Storage-specific ArchUnit rules live with Object Storage.
|
||||
- Root architecture rules remain strict and cross-module only.
|
||||
- Gradle stays at 9.0.0 and gains exact wrapper/distribution validation.
|
||||
- Docker copies `config/**`; Git evidence is execution-scoped and supplied by `gitRevision`.
|
||||
- SpotBugs uses source-set runtime classpaths and fails on missing analysis classes.
|
||||
- The dependency-purity task avoids execution-time `Task.project` access and truthfully declares
|
||||
its configuration-cache incompatibility while it still inspects project configurations.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Client-Safe Error Boundary Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** approved by the user's instruction to apply the detailed P1/P2 review sequentially
|
||||
**Scope:** HTTP error envelopes in `adapter:inbound:web` and the `sample-portfolio` domain advice
|
||||
|
||||
## Context
|
||||
|
||||
Several handlers pass `Exception#getMessage()`, rejected request values, or a raw request URL into
|
||||
the public error envelope. Those values are not a stable API contract and can contain identifiers,
|
||||
tokens, uploaded values, configuration details, or internal diagnostics. Persistence and outbound
|
||||
dependency failures already use fixed client-safe messages; the rest of the HTTP boundary must
|
||||
follow the same rule.
|
||||
|
||||
## Decision
|
||||
|
||||
The inbound adapter owns a message allowlist keyed by stable error code. Handlers may expose only:
|
||||
|
||||
- stable `code`, `category`, HTTP status, and `retryable` from `ApiErrorCode`;
|
||||
- fixed, code-specific client messages;
|
||||
- bounded structural details such as field name, validation reason code, expected Java type,
|
||||
supported HTTP methods, or supported media types.
|
||||
|
||||
They must not expose exception messages, rejected values, raw request URLs, adapter/configuration
|
||||
diagnostics, opaque cursors, authentication diagnostics, resource identifiers, or duplicate domain
|
||||
values. Bean Validation interpolated/default messages are also discarded because custom templates
|
||||
can include the validated value. Validation details contain only normalized server-owned property
|
||||
names plus allowlisted reason codes and fixed messages; collection/map keys and indices are removed.
|
||||
|
||||
`ClientSafeErrorMessages` is extended for skeleton-wide operational codes. The sample keeps its
|
||||
domain wording in a separate package-private `PortfolioClientSafeErrorMessages`, preserving the
|
||||
rule that production modules do not know sample business concepts.
|
||||
|
||||
## Public Messages
|
||||
|
||||
Representative mappings are fixed as follows:
|
||||
|
||||
- `MAPPING_FAILED` → `Request data could not be mapped`;
|
||||
- `BAD_PARAMETER` → `Request parameter is invalid`;
|
||||
- `INVALID_TOKEN` → `Authentication token is invalid`;
|
||||
- `UNAUTHENTICATED` → `Authentication is required`;
|
||||
- authorization denials → `Access is denied`;
|
||||
- `PRECONDITION_FAILED` → `Resource state changed; refresh and retry`;
|
||||
- page/cursor failures → generic corrective text, with safe field/reason details retained;
|
||||
- `ADAPTER_DISABLED` and internal classifications → `Internal server error`;
|
||||
- domain not-found/conflict/invariant codes → fixed noun-level text with no ID/title value.
|
||||
|
||||
Transport overrides use fixed wording and retain only safe protocol metadata. For example, 405
|
||||
still emits `Allow`, while both controller-route (`NoHandlerFoundException`) and static-resource
|
||||
(`NoResourceFoundException`) 404s use the same envelope without echoing the request URL.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests inject conspicuous secret sentinels into exception messages, rejected values, URLs, tokens,
|
||||
IDs, and duplicate titles. Every resulting response must preserve its status/code/category while
|
||||
excluding the sentinel from both `error.message` and `error.details`.
|
||||
|
||||
Validation tests additionally place sentinels in interpolated/default messages and iterable
|
||||
keys/indices. A real MockMvc resource-resolution request verifies the Spring 7
|
||||
`NoResourceFoundException` path rather than calling the advice method directly.
|
||||
|
||||
The focused module suites remain the primary verification:
|
||||
|
||||
- `:adapter:inbound:web:test` for operational and transport handlers;
|
||||
- `:sample-portfolio:test` for domain advice and sample wire behavior;
|
||||
- `verifyCleanArchitectureDependencies` for dependency direction.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no change to error codes, categories, statuses, or retryability;
|
||||
- no suppression of server-side logs or tracing in this batch;
|
||||
- no application/domain dependency on HTTP response types;
|
||||
- no generic exception-message sanitizer based on regexes or truncation;
|
||||
- no staging, commit, amend, or push by an agent.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Conditional Inbound Transport Boundary Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** approved by the user's instruction to apply the detailed P1/P2 review sequentially
|
||||
**Scope:** the opt-in GraphQL, gRPC, and WebSocket leaf modules and their release evidence
|
||||
|
||||
## Context
|
||||
|
||||
The three leaves are registered and tested independently, but neither `app-bootstrap` nor
|
||||
`sample-portfolio` has a production dependency on them. That omission is intentional: adding a
|
||||
classpath edge today would activate GraphQL, start a plaintext/reflection-enabled gRPC server by
|
||||
default, and unconditionally expose a wildcard-origin STOMP broker that serializes arbitrary domain
|
||||
events. The leaf documentation nevertheless describes sample contributions that do not exist, and
|
||||
the ordinary root `check` can become `NO-SOURCE` without a transport-specific positive-count and
|
||||
zero-skip qualification gate.
|
||||
|
||||
P1 therefore makes opt-in status executable and makes accidental activation fail closed. It does
|
||||
not add these leaves to the default runtime or claim the P2 production baselines.
|
||||
|
||||
## Runtime Membership SSOT
|
||||
|
||||
Every entry in `config/architecture/modules.json` gains an exact `runtime_memberships` array whose
|
||||
values are limited to the two composition roots: `app-bootstrap` and `sample-portfolio`.
|
||||
|
||||
- A composition root includes itself in its membership.
|
||||
- Direct production `api`/`implementation`/`compileOnly`/`runtimeOnly` project dependencies must
|
||||
equal the registry members for that root, excluding the root itself.
|
||||
- An empty array means the leaf is built and architecture-checked but absent from both shipped
|
||||
runtime graphs. GraphQL, gRPC, WebSocket, and Mongo remain in this state.
|
||||
- Test fixtures and custom qualification configurations do not change production membership.
|
||||
|
||||
Settings validation is fail-closed for missing, duplicate, or unknown membership names. A Gradle
|
||||
verification task compares the registry to both composition roots and is part of `check`.
|
||||
|
||||
## Explicit Qualification Composition
|
||||
|
||||
`app-bootstrap` owns a `conditionalTransportTest` source set whose classpath explicitly includes
|
||||
the three opt-in leaves. It proves that the opt-in artifacts resolve together while the registry
|
||||
still declares them absent from both default runtime graphs. It is evidence composition, not a new
|
||||
production dependency edge.
|
||||
|
||||
The root registers exact qualification `Test` tasks for GraphQL, gRPC, and WebSocket. Each task:
|
||||
|
||||
- names required test classes rather than broad discovery;
|
||||
- fails on no match or no discovery;
|
||||
- always reruns in UTC;
|
||||
- fails if the root suite reports any skipped test.
|
||||
|
||||
An aggregate `conditionalTransportQualification` task depends on the composition contract and all
|
||||
three exact lanes. CI invokes it explicitly from the existing release-blocking quality job, and the
|
||||
gate matrix records the task.
|
||||
|
||||
## gRPC P1 Boundary
|
||||
|
||||
gRPC activation becomes explicit and local-only until a later TLS/mTLS design exists:
|
||||
|
||||
- `enabled=false` and `reflectionEnabled=false` are defaults; missing properties create no runner,
|
||||
health manager, reflection service, or listener.
|
||||
- The current insecure credential mode requires an explicit local-development override and a
|
||||
loopback bind address. Non-loopback insecure bind fails startup.
|
||||
- Feature services require a caller-supplied authentication policy/interceptor. Missing or invalid
|
||||
metadata returns stable `UNAUTHENTICATED`; valid metadata reaches the service.
|
||||
- Health remains a local lifecycle probe; reflection is a separate explicit flag.
|
||||
- The error interceptor wraps `ServerCall.close`, so handler throws, listener throws, ordinary
|
||||
`responseObserver.onError`, and raw `StatusRuntimeException` all pass the same sanitizer.
|
||||
Recognized `ApiErrorCarrier` causes produce stable code/category trailers; unrecognized status
|
||||
descriptions become fixed `INTERNAL_ERROR` with no raw diagnostic.
|
||||
|
||||
A real ephemeral Netty unary service verifies authentication, reflection-off, all error paths, and
|
||||
sentinel redaction. TLS/mTLS, external bind, deadlines, streaming, and protobuf compatibility are
|
||||
P2 and remain unclaimed.
|
||||
|
||||
## GraphQL P1 Boundary
|
||||
|
||||
GraphQL remains classpath-selected: its absence from the default runtime is the disable mechanism,
|
||||
and the qualification classpath is the explicit opt-in mechanism. The wire lane starts a real
|
||||
random-port MVC server and crosses HTTP JSON, Spring Security, and CORS.
|
||||
|
||||
It verifies unauthenticated rejection, authenticated health success, allowed/disallowed origins,
|
||||
GraphiQL disabled, production-style introspection disabled, stable carrier errors, unknown errors,
|
||||
and absence of distinct secret sentinels from the complete response body. The existing resolver is
|
||||
changed only if a failing wire contract proves unsafe behavior.
|
||||
|
||||
Feature schema/resolvers, field authorization, depth/cost, persisted queries, DataLoader, schema
|
||||
compatibility, and subscriptions remain P2.
|
||||
|
||||
## WebSocket P1 Boundary
|
||||
|
||||
WebSocket gains `ca-skeleton.websocket.enabled=false`; both configuration and broadcaster are
|
||||
conditional. Enabled settings reject wildcard/blank origins and invalid endpoint/destination
|
||||
shapes.
|
||||
|
||||
The inbound channel requires an authenticated handshake principal, permits subscription only to
|
||||
the configured server topic, permits authenticated application sends under `/app/**`, and rejects
|
||||
client sends to `/topic/**`. A custom STOMP error handler emits only a fixed client-safe code.
|
||||
|
||||
The broadcaster no longer serializes arbitrary `@DomainEvent` objects. It consults an explicit
|
||||
projection allowlist; an event without exactly one projection is not sent. Projection output is a
|
||||
bounded primitive map, not the domain object graph.
|
||||
|
||||
A real random-port WebSocket/STOMP lane verifies disabled absence, origin/auth/connect/subscribe,
|
||||
server push, broker-send rejection, error redaction, and no projection/no broadcast. The simple
|
||||
broker remains local/R1 only; broker relay, cross-node durability, replay, backpressure, and a
|
||||
domain-specific versioned projection catalog remain P2.
|
||||
|
||||
## Documentation Truthfulness
|
||||
|
||||
Leaf READMEs and CLAUDE files describe only code that exists. Sample GraphQL schemas, gRPC services,
|
||||
and WebSocket publishers are future adoption examples, not current runtime features. Each document
|
||||
states the activation switch, exact P1 evidence, and unimplemented P2 limits.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- adding any of the three leaves to a shipped default runtime;
|
||||
- adding a production project dependency edge outside the registry;
|
||||
- claiming production readiness from local loopback/simple-broker tests;
|
||||
- implementing sample feature APIs or domain payloads;
|
||||
- staging, committing, amending, or pushing changes.
|
||||
@@ -0,0 +1,79 @@
|
||||
# P2 Verification Governance Refactoring Design
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the remaining fail-open verification paths without changing production behavior or adding
|
||||
unadopted runtime capabilities. P2 strengthens qualification tasks, tracked contract resources,
|
||||
CI parser evidence, JSON Schema conformance, registry ownership, and bounded documentation debt.
|
||||
|
||||
## Scope and sequence
|
||||
|
||||
1. Move strict qualification `Test` registration to each owner leaf through one shared convention.
|
||||
2. Resolve tracked repository contract resources from an explicit repository root and fail when
|
||||
tracked files or directories are absent.
|
||||
3. Exercise the real gate-matrix shell validator through isolated mutation fixtures.
|
||||
4. Validate every Redis program manifest with the committed Draft 2020-12 schema.
|
||||
5. Make the tracked registry set explicit, resolve every `required_test` identifier, and govern
|
||||
temporary runbook stubs with owners and expiry dates.
|
||||
6. Apply bounded P2 cleanup: module-doc link coverage, migration-neutral gate labels, and
|
||||
deterministic outbound HTTP timeout tests.
|
||||
|
||||
Each item is independently reviewable. A later item may reuse infrastructure from an earlier item,
|
||||
but no batch may weaken an existing check while waiting for a subsequent batch.
|
||||
|
||||
## Qualification convention
|
||||
|
||||
The owner project applies `gradle/strict-qualification-test.gradle` and registers its own exact
|
||||
qualification tasks. The root project only aggregates absolute task paths and validates resulting
|
||||
JUnit XML.
|
||||
|
||||
Every strict qualification task must:
|
||||
|
||||
- name at least one required FQCN;
|
||||
- depend on compilation and fail before test execution when any required class file is absent;
|
||||
- use exact JUnit filters with no-match and no-discovery failures enabled;
|
||||
- force fresh execution in UTC and emit JUnit XML;
|
||||
- reject skipped tests and require a positive, failure-free XML count.
|
||||
|
||||
This applies to conditional transports, Messaging evidence lanes, object-storage release lanes,
|
||||
the Poster migration lane, and the app-bootstrap conditional-composition proof. Ordinary optional
|
||||
or quarantine tests are deliberately excluded.
|
||||
|
||||
## Repository contract resources
|
||||
|
||||
`app-bootstrap` injects `ca.repository.root` into contract tests. A package-private resolver
|
||||
normalizes the root, rejects traversal, and exposes `requireTrackedFile` and
|
||||
`requireTrackedDirectory`. Missing tracked resources are assertion failures, never assumptions.
|
||||
Assumptions remain valid only for truly optional external infrastructure.
|
||||
|
||||
## CI parser evidence
|
||||
|
||||
The gate-matrix validator accepts an optional repository-root argument. Contract tests construct a
|
||||
minimal temporary repository fixture and invoke the actual shell script. Mutations for deceptive
|
||||
step names, execution-suppressing flags, missing or duplicated gates, and unregistered tasks must
|
||||
produce non-zero exits with stable diagnostics. Java must not contain a second parser.
|
||||
|
||||
## Schema and registry governance
|
||||
|
||||
- Redis manifests are validated by a Draft 2020-12 implementation in addition to existing catalog
|
||||
cross-checks.
|
||||
- A registry catalog has an exact one-to-one relationship with tracked `docs/registries/*.yaml`.
|
||||
- Stable `required_test` IDs resolve through a tracked catalog to a single owner Gradle path and
|
||||
source test/method. Unknown, duplicate, and dangling mappings fail.
|
||||
- Temporary runbook stubs are listed in tracked debt data with owner, issue, start, and sunset.
|
||||
Missing or expired debt entries fail.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No GraphQL feature schema, cost/depth policy, gRPC TLS/streaming, WebSocket relay, or other
|
||||
production capability is introduced.
|
||||
- No lockfile consolidation, version-catalog migration, JVM test-suite migration, or broad module
|
||||
boundary change is included.
|
||||
- Root Gradle capability extraction and a typed settings/build registry model remain separate
|
||||
refactors unless their benefit can be proven without expanding this verification change.
|
||||
|
||||
## Verification
|
||||
|
||||
Each batch starts with a focused failing contract and finishes with its owner `check`. Final
|
||||
verification runs root `test`, `check`, architecture/dependency/runtime membership gates, CI shell
|
||||
validators, dependency locks, public-path/env gates, and `git diff --check`.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Redis Session HTTP Boundary Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** approved by the user's instruction to apply the reviewed P1/P2 work sequentially
|
||||
**Scope:** composition of inbound browser-session security with the outbound versioned Redis session repository
|
||||
|
||||
## Context
|
||||
|
||||
Inbound-web unit contracts prove CSRF, fixation, hardened cookie settings, and primitive security
|
||||
snapshot behavior with `MockHttpSession`/in-memory repositories. Cache-redis contracts prove the
|
||||
versioned session repository and Lua semantics against Redis. No test currently crosses the actual
|
||||
Spring Session filter, production SecurityFilterChain, real Redis, and a second application context.
|
||||
|
||||
Putting this test in inbound-web would require a forbidden dependency on the outbound Redis leaf.
|
||||
The composition root already depends on both leaves and owns the `redisCompositionTest` source set,
|
||||
so app-bootstrap is the correct boundary owner.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a tagged `redis-session-http` integration contract under app-bootstrap's existing
|
||||
`redisCompositionTest` source set. Ordinary `redisCompositionTest` excludes the tag. A new explicit
|
||||
`redisSessionHttpIntegrationTest` task includes only that tag, fails on no discovery or any skip,
|
||||
always reruns, pins UTC, and passes the checked-in Redis image registry path.
|
||||
|
||||
The task is deliberately not attached to ordinary local `check`, because it requires Docker. It is
|
||||
added to the existing release-blocking `redis-standalone` CI job, which is the Docker-capable Redis
|
||||
lane. Docker availability and container startup are attempted directly; no condition, assumption,
|
||||
or environment flag may convert absence into a skip.
|
||||
|
||||
The test loads `redis.approved.image` from `src/gradle/redis-test-images.properties` and rejects an
|
||||
unpinned reference. It creates an ephemeral CA/server certificate and a named, least-privilege ACL
|
||||
user, then connects with TLS, full hostname verification, and explicit CA trust. A
|
||||
runtime-generated Redis password and 32-byte HMAC are supplied through caller-owned versioned
|
||||
material; no secret value is checked in, passed on the Redis command line, or logged. Missing
|
||||
Docker or OpenSSL is a hard failure, not a skip.
|
||||
|
||||
The custom source set needs the Spring Session API at compile time. App-bootstrap therefore adds
|
||||
`spring-session-core` only to `redisCompositionTestImplementation`; the existing version is reused
|
||||
and the lockfile records the new custom compile configuration without changing a dependency
|
||||
version.
|
||||
|
||||
## HTTP/Session Contract
|
||||
|
||||
1. A state-changing request without CSRF is 403.
|
||||
2. Accessing the CSRF endpoint emits the configured Secure, non-HttpOnly CSRF cookie.
|
||||
3. Login with matching cookie/header creates only the bounded primitive authentication snapshot.
|
||||
4. The session cookie is host-only, Secure, HttpOnly, SameSite=Lax, path `/`, and session-scoped.
|
||||
5. After the first web context closes, a second independent context restores `/whoami` from the
|
||||
same cookie through real Redis.
|
||||
6. Logout force-revokes/tombstones the session; the old cookie is unauthenticated and a previously
|
||||
loaded stale session object cannot save over the tombstone.
|
||||
7. If Redis becomes unavailable during session lookup, the request fails closed before the
|
||||
protected controller and the surfaced exception graph contains only the repository's fixed
|
||||
availability message, not endpoint/password/session material.
|
||||
|
||||
The RED run exposed two production composition gaps which are part of this boundary:
|
||||
|
||||
- the primitive security-context repository must wrap the response and persist before response
|
||||
commit, otherwise a successful response can commit before the first session is created;
|
||||
- the API security chain disables Spring Security's request cache, otherwise an unauthenticated
|
||||
request stores a `DefaultSavedRequest` framework graph that the primitive session codec correctly
|
||||
rejects.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Inbound-web remains provider-neutral and has no outbound dependency.
|
||||
- Cache-redis keeps Redis keys, Lua, codec, HMAC, and tombstone policy private.
|
||||
- App-bootstrap assembles both adapters only for a cross-module composition contract.
|
||||
- No production dependency edge or dependency version changes; only a custom-test compile
|
||||
configuration is added to the existing lock entry.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redis Sentinel/Cluster sessions (production activation explicitly rejects them today);
|
||||
- browser-engine proof of SameSite behavior;
|
||||
- credential/certificate rotation qualification (the fixture still uses mandatory TLS, full
|
||||
hostname verification, explicit trust, and a named ACL user);
|
||||
- attaching Docker work to ordinary `check`;
|
||||
- staging, commit, amend, or push by an agent.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Verification Purity Refactoring Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** approved by the user's instruction to apply the P1/P2 review sequentially
|
||||
**Scope:** stale traceable JAR verification/cleanup and public-path snapshot verification/update
|
||||
|
||||
## Context
|
||||
|
||||
Two root Gradle verification paths currently mutate files while they are expected to be safe gates:
|
||||
|
||||
- every `Jar` task deletes stale traceable archives in `doFirst`, and
|
||||
`verifyNoStaleTraceableJars` depends on `cleanStaleTraceableJars`;
|
||||
- `verifyPublicPathSnapshot` creates a missing snapshot and updates drift when
|
||||
`-PapprovePublicPathChange` is supplied.
|
||||
|
||||
That makes `check` capable of hiding the state it is meant to detect. This batch restores the
|
||||
standard contract: verification observes and fails, while explicitly named maintenance tasks own
|
||||
writes.
|
||||
|
||||
## Considered Approaches
|
||||
|
||||
### Keep the root build logic in place and inspect source text in tests
|
||||
|
||||
This is the smallest diff, but a source assertion cannot prove task side effects. Rejected.
|
||||
|
||||
### Invoke the entire repository build from a copied checkout
|
||||
|
||||
This tests the actual root build but requires copying all 19 leaves and resolving every root plugin
|
||||
for two small contracts. It is slow and couples the tests to unrelated configuration. Rejected.
|
||||
|
||||
### Extract only the two task concerns into applied Gradle scripts and exercise them with TestKit
|
||||
|
||||
Selected. The production root applies the same scripts that an isolated functional fixture uses.
|
||||
The fixture observes exit status and filesystem state, so it proves behavior rather than source
|
||||
shape. This is a bounded extraction required for testability, not the broad P2 root-build rewrite.
|
||||
|
||||
## Archive Hygiene Contract
|
||||
|
||||
`gradle/archive-hygiene.gradle` owns stale traceable archive discovery and the two root tasks:
|
||||
|
||||
- `verifyNoStaleTraceableJars` reports every stale archive and fails without deleting anything;
|
||||
- `cleanStaleTraceableJars` deletes only names matching the traceable archive pattern for a known
|
||||
`Jar` task and never deletes the current archive;
|
||||
- normal `jar`/`bootJar` execution never performs cleanup.
|
||||
|
||||
The existing traceable version naming and manifest metadata remain unchanged.
|
||||
|
||||
## Public-Path Snapshot Contract
|
||||
|
||||
`gradle/public-path-snapshot.gradle` owns canonicalization and two root tasks:
|
||||
|
||||
- `verifyPublicPathSnapshot` fails when the env file or committed snapshot is missing, when content
|
||||
drifts, or when the update-only approval property is passed to the verifier. It never creates
|
||||
directories or writes files;
|
||||
- `updatePublicPathSnapshot` requires `-PapprovePublicPathChange` and writes the canonical snapshot.
|
||||
|
||||
A clean-worktree requirement is intentionally not used: the normal update workflow necessarily has
|
||||
an intentional `.env` change. Explicit task naming, the approval property, and the resulting diff
|
||||
are the review boundary.
|
||||
|
||||
The canonical header names `updatePublicPathSnapshot`, so documentation and the committed snapshot
|
||||
do not instruct users to mutate through a verification task.
|
||||
|
||||
## Testing
|
||||
|
||||
`BuildVerificationPurityContractTest` runs from an isolated `functionalTest` source set using Gradle
|
||||
TestKit against temporary projects that apply the production scripts directly. Keeping TestKit off
|
||||
the ordinary `testRuntimeClasspath` prevents Gradle's SLF4J provider from replacing Logback during
|
||||
Spring tests. It proves:
|
||||
|
||||
1. a normal `jar` leaves a matching stale archive untouched;
|
||||
2. verification fails and preserves the stale archive;
|
||||
3. explicit cleanup deletes the stale archive but preserves the current archive;
|
||||
4. missing/drifted public-path snapshots cause read-only failure;
|
||||
5. the verifier rejects the update approval property;
|
||||
6. only the explicit updater with approval creates or changes the snapshot.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no change to archive naming, versions, manifests, production dependency versions, or project edges;
|
||||
- only the new isolated functional-test configurations are added to `app-bootstrap/gradle.lockfile`;
|
||||
- no public-path allow-list value change;
|
||||
- no broad root Gradle convention-plugin migration;
|
||||
- no staging, commit, amend, or push by an agent.
|
||||
@@ -0,0 +1,448 @@
|
||||
# Warning-Zero Build Refactoring Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** Approved design, pending written-spec review
|
||||
**Scope:** Java compilation, Error Prone, Checkstyle, SpotBugs, test JVM diagnostics, expected-negative
|
||||
shell-contract output, and intentional legacy/architecture-test compatibility seams.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the standard repository build both functionally green and warning-clean. A successful build
|
||||
must no longer conceal compiler warnings, test-source SpotBugs findings, ignored Checkstyle
|
||||
findings, deprecated third-party API calls, or expected-negative subprocess diagnostics that look
|
||||
like real failures.
|
||||
|
||||
The final local proof is a fresh `./gradlew clean build --warning-mode=all --no-daemon
|
||||
--console=plain` with:
|
||||
|
||||
- exit code zero;
|
||||
- zero compiler/Error Prone warnings;
|
||||
- zero Checkstyle and SpotBugs findings in every executed source set;
|
||||
- zero `SpotBugs ended with exit code 1` messages;
|
||||
- zero OpenJDK CDS warnings from test JVMs;
|
||||
- no successful Redis lab contract printing its expected-negative child diagnostics;
|
||||
- only the five currently intentional optional-adapter/TestKit skips, with no qualification lane
|
||||
silently skipped.
|
||||
|
||||
## Baseline Evidence
|
||||
|
||||
The fresh pre-change command completed successfully in 20 minutes 26 seconds with 283 of 283 tasks
|
||||
executed. Success did not mean warning-clean:
|
||||
|
||||
- 123 compiler warning diagnostics across 19 warning rules (122 distinct file-line/rule
|
||||
coordinates because one line emits two separate removal diagnostics);
|
||||
- one test-source SpotBugs `DMI_RANDOM_USED_ONLY_ONCE` finding;
|
||||
- ten OpenJDK CDS warning lines from Mockito-using test JVMs;
|
||||
- 82 `redis-lab:` expected-negative stderr lines;
|
||||
- five intentional skipped tests;
|
||||
- no test failure, compiler error, Checkstyle finding, SpotBugs analysis error, or missing analysis
|
||||
class.
|
||||
|
||||
The Gradle Problems report is an informational index over compiler diagnostics, not a separate
|
||||
defect. It must become empty as a consequence of removing the underlying warnings; it must not be
|
||||
hidden.
|
||||
|
||||
### Warning inventory traceability
|
||||
|
||||
| Rule | Diagnostic instances | Required resolution |
|
||||
| --- | ---: | --- |
|
||||
| `removal` | 46 | Exact legacy lifecycle/suppression policy in section 4 |
|
||||
| `MissingOverride` | 16 | Add annotations to the implementing test fakes in section 2 |
|
||||
| `StringCaseLocaleUsage` | 10 | `Locale.ROOT` behavior fixes and test cleanup in sections 1–2 |
|
||||
| `SameNameButDifferent` | 9 | Qualify the two Redis nested enum types in section 2 |
|
||||
| `DefaultCharset` | 9 | Explicit UTF-8 test data in sections 1–2 |
|
||||
| `ArrayRecordComponent` | 7 | Exact record policies and copy regressions in section 2 |
|
||||
| `CanonicalDuration` | 5 | `Duration.ofDays(3)` in section 2 |
|
||||
| `StringSplitter` | 4 | ETag scanner plus three grammar-specific test fixes in sections 1–2 |
|
||||
| `EmptyCatch` | 4 | Cleanup failure propagation in section 1 |
|
||||
| `StringConcatToTextBlock` | 2 | Byte-identical text blocks in section 2 |
|
||||
| `InvalidBlockTag` | 2 | Inline-code annotation names in section 2 |
|
||||
| `BigDecimalLiteralDouble` | 2 | Method-only intentional-fixture suppressions in section 5 |
|
||||
| `TypeParameterUnusedInFormals` | 1 | Spring Session method-only suppression in section 2 |
|
||||
| `ThreadLocalUsage` | 1 | Instance-isolation regression and field-only suppression in section 2 |
|
||||
| `ReferenceEquality` | 1 | Redis catalog identity regression and constructor-only suppression in section 2 |
|
||||
| `MissingSummary` | 1 | Public Javadoc summary in section 2 |
|
||||
| `JavaTimeDefaultTimeZone` | 1 | Fixed date/explicit zone in section 1 |
|
||||
| `FutureReturnValueIgnored` | 1 | Observe the future in section 1 |
|
||||
| `BooleanLiteral` | 1 | Literal assertion cleanup in section 2 |
|
||||
|
||||
This table accounts for all 123 Error Prone/compiler-warning diagnostics. The separate
|
||||
`-Xlint:deprecation,unchecked` inventory is covered by the third-party migrations and exact legacy
|
||||
seam policy below; it is not allowed to disappear through a source-set suppression.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not remove the legacy poster-image endpoint, `StoredObjectResponse`, raw-key compatibility
|
||||
data, or legacy object-storage adapters during warning cleanup.
|
||||
- Do not switch the sample runtime from legacy to publication mode without the separately required
|
||||
API, data-adoption, dual-read, and external-consumer approvals.
|
||||
- Do not apply module-wide or task-wide suppression for `removal`, `deprecation`, `unchecked`, or
|
||||
Error Prone rules.
|
||||
- Do not weaken architecture rules or change deliberately forbidden bytecode merely to silence a
|
||||
fixture warning.
|
||||
- Do not make quarantine tests blocking; their separate sunset and reporting policy remains
|
||||
unchanged.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Fix behavior defects at their source before applying any suppression.
|
||||
2. Use suppression only where a framework signature, identity invariant, intentional violation
|
||||
fixture, or approved compatibility seam makes the warning inapplicable.
|
||||
3. Scope every suppression to the smallest class, method, field, constructor, or fixture that
|
||||
explains it, with a nearby rationale.
|
||||
4. Replace deprecated third-party APIs with their typed current equivalents and verify behavior,
|
||||
not only compilation.
|
||||
5. Capture expected-negative diagnostics and assert them exactly; never discard stderr globally.
|
||||
6. Add blocking gates only after the current warning inventory is clean.
|
||||
|
||||
## Component Design
|
||||
|
||||
### 1. Real behavior defects
|
||||
|
||||
#### Locale-independent identifiers
|
||||
|
||||
Use `Locale.ROOT` for security roles, notification configuration keys, repository ACL names, and
|
||||
test comparisons. Add Turkish-default-locale regressions that restore the original default locale
|
||||
in `finally`:
|
||||
|
||||
- `JwtToAuthenticatedPrincipalConverter`: `admin` must always become `ROLE_ADMIN`;
|
||||
- `RoutingNotifier`: diagnostic keys for `EMAIL` must remain `app.notification.routes.email...`;
|
||||
- `RepoStatsAclMapper`: `IDEA/Repo` must normalize to `idea/repo`.
|
||||
|
||||
This is a correctness fix: the current code can generate dotless/dotted Turkish-I variants in
|
||||
authorization and operational identifiers.
|
||||
|
||||
#### Quote-aware ETag list parsing
|
||||
|
||||
Do not replace `String.split(",")` with another delimiter-only splitter. A comma is legal inside a
|
||||
quoted opaque entity tag. `ETags` will use a small scanner that:
|
||||
|
||||
- splits only on commas outside a quoted string;
|
||||
- preserves weak-tag prefixes and the existing trimming behavior;
|
||||
- treats malformed/unclosed quotes as non-matching input rather than guessing a token;
|
||||
- preserves wildcard and ordinary multi-value behavior.
|
||||
|
||||
Regressions cover a single comma-bearing tag, a mixed list containing a weak comma-bearing tag,
|
||||
ordinary lists, wildcard, stale values, blank input, and malformed quoting.
|
||||
|
||||
#### Asynchronous and cleanup failures
|
||||
|
||||
- `AsyncGracefulShutdownBehaviorTest` retains the returned `Future<?>` and observes `get()` so a
|
||||
background assertion or exception cannot disappear.
|
||||
- Outbox test cleanup methods propagate or wrap resource-destruction failures with the original
|
||||
cause instead of using empty catches.
|
||||
- Tests use fixed dates, UTF-8, and explicit locale rather than host defaults.
|
||||
|
||||
### 2. Production warning cleanup with preserved invariants
|
||||
|
||||
#### Redis primitive ownership
|
||||
|
||||
`RedisPrimitiveInvocation` intentionally requires descriptor object identity. Value equality would
|
||||
admit a descriptor created by another catalog and weaken the closed-catalog invariant. Keep the
|
||||
reference comparison, add an exact constructor-level `ReferenceEquality` suppression, and add a
|
||||
regression proving value-equal but non-identical cross-catalog descriptors are rejected.
|
||||
|
||||
Qualify both nested `ExpectedKind` types with their enclosing record names rather than renaming the
|
||||
types. This removes `SameNameButDifferent` without changing bytecode or package-local consumers.
|
||||
|
||||
#### Framework-owned generic signature
|
||||
|
||||
`RedisVersionedSession.<T>getAttribute(String)` must retain Spring Session's inherited signature.
|
||||
Apply a method-only `TypeParameterUnusedInFormals` suppression with the interface-contract reason.
|
||||
|
||||
#### Instance-owned retry context
|
||||
|
||||
`OutboundRetryPolicy` keeps its instance `ThreadLocal`. Making it static would leak call context
|
||||
between policy instances on the same thread. Add a field-only `ThreadLocalUsage` suppression and a
|
||||
regression proving policy A's context is invisible to policy B and is cleared by `endCall()`.
|
||||
|
||||
#### Array-bearing records
|
||||
|
||||
- `NotificationCiphertext` retains its public array components because it already clones inputs and
|
||||
accessors, implements content-based equality/hash code, and redacts `toString`. Add focused
|
||||
defensive-copy/equality/redaction tests and an exact record-level suppression.
|
||||
- The four internal session command/outcome records in `VersionedRedisSessionStore` remain internal
|
||||
transport envelopes. Preserve defensive copies, document that generated record equality is not
|
||||
their contract, add constructor/accessor copy tests, and suppress `ArrayRecordComponent` on each
|
||||
exact record.
|
||||
- The private test fake in `RedisVersionedSessionRepositoryTest` receives the same exact nested-type
|
||||
treatment; no public type is changed.
|
||||
|
||||
#### Mechanical behavior-neutral fixes
|
||||
|
||||
- Express 72 hours as `Duration.ofDays(3)` in application/bootstrap/sample settings and matching
|
||||
tests.
|
||||
- Add the missing public Javadoc summary in `TracingSampleRateResolver`, and render annotation names
|
||||
such as `@WebMvcTest` as inline `{@code ...}` rather than accidental block tags.
|
||||
- Add missing `@Override` annotations in sample test fakes.
|
||||
- Replace readability-only string concatenations with text blocks where the literal bytes remain
|
||||
identical.
|
||||
- Replace Boolean wrapper comparisons with boolean literals.
|
||||
- For the three test-only delimiter warnings, preserve each existing grammar explicitly: retain CSV
|
||||
empty-token filtering with a limit-bearing split or scanner, scan mapping-path segments without
|
||||
changing leading/trailing-empty behavior, and parse the single HTTP byte-range hyphen with an
|
||||
asserted `indexOf` boundary. These are not allowed to inherit the ETag scanner because their
|
||||
grammars differ.
|
||||
|
||||
### 3. Third-party API migration
|
||||
|
||||
#### Jackson 3
|
||||
|
||||
In `LocalJsonSchemaRegistry`, replace deprecated `JsonNode.isTextual()`/`textValue()` with
|
||||
`isString()`/`stringValue()`. Existing type guards remain, and JSON schema identity/reference/value
|
||||
tests prove identical acceptance and rejection behavior.
|
||||
|
||||
In `DeterministicEnvelopeWriter`, replace the deprecated convenience call with
|
||||
`jsonFactory.createGenerator(ObjectWriteContext.empty(), output, JsonEncoding.UTF8)`, the
|
||||
non-deprecated Jackson 3.0.2 overload. Preserve canonical byte output; the existing deterministic
|
||||
envelope golden tests are the behavior gate.
|
||||
|
||||
#### Lettuce
|
||||
|
||||
Convert both finite canonical scores to `BigDecimal`, build one inclusive
|
||||
`Range<? extends Number>` for each invocation, and call the typed `zcount(key, range)` and
|
||||
`zrangebyscoreWithScores(key, range, Limit.create(offset, count))` overloads. Preserve inclusive
|
||||
bounds, offset, count, and exact reply mapping. A dynamic-proxy regression verifies both typed
|
||||
overloads are selected; sorted-set primitive contract tests verify results.
|
||||
|
||||
#### AWS SDK retry
|
||||
|
||||
Replace old `RetryPolicy` and core `EqualJitterBackoffStrategy` with `StandardRetryStrategy`, the
|
||||
retries API half-jitter exponential backoff, `maxAttempts`, and
|
||||
`ClientOverrideConfiguration.Builder.retryStrategy`. Tests assert maximum attempts and normal versus
|
||||
throttling backoff configuration. The focused object-storage check must cover provider assembly;
|
||||
compile-only success is insufficient.
|
||||
|
||||
#### Testcontainers Toxiproxy
|
||||
|
||||
Use the Testcontainers 2 toxiproxy package and a typed `ToxiproxyClient`/`Proxy` with an explicit
|
||||
exposed proxy port. Fault tests must still prove cut and restore behavior against MinIO. Dependency
|
||||
and lock changes stay inside the object-storage leaf.
|
||||
|
||||
#### Remaining JDK/generic deprecations
|
||||
|
||||
- Replace deprecated `new URL(String)` test construction with `URI.create(...).toURL()`.
|
||||
- Replace the varargs `thenReturn(firstFuture, secondFuture)` stub in
|
||||
`S3ConditionalObjectControlStoreTest` with two chained single-value `thenReturn(...)` calls, so
|
||||
Mockito does not create the unchecked generic `CompletableFuture<PutObjectResponse>[]` array.
|
||||
- Resolve every `-Xlint:deprecation,unchecked` location individually; do not suppress the source
|
||||
set.
|
||||
|
||||
### 4. Legacy object-storage compatibility seam
|
||||
|
||||
The canonical object-storage ports and sample publication path already exist. The legacy runtime is
|
||||
still selected in local/test configuration and cannot be deleted solely to silence warnings.
|
||||
|
||||
Keep `@Deprecated(forRemoval = true)` on the genuinely replaced whole-byte contracts:
|
||||
|
||||
- `ObjectStoragePort`;
|
||||
- `StoredObject`;
|
||||
- `ObjectStorageSettings`.
|
||||
|
||||
Apply `removal` suppression only to exact compatibility owners:
|
||||
|
||||
- `ObjectStoragePort` for its legacy receipt return type;
|
||||
- `FilesystemObjectStorageAdapter` and `S3ObjectStorageAdapter`;
|
||||
- `UploadPosterImageUseCase`;
|
||||
- the legacy bean method in `PosterImageApiConfig`;
|
||||
- `LegacyPosterImageController`;
|
||||
- `PosterWebMapper.toStoredObjectResponse`;
|
||||
- named legacy characterization test classes and single legacy-receipt test methods.
|
||||
|
||||
The six `application.storage.migration` types and `AdoptLegacyPosterImageUseCase` are the mechanism
|
||||
used to complete data adoption and currently have no replacement. Change their lifecycle marker
|
||||
from `@Deprecated(forRemoval = true)` to plain `@Deprecated`; use exact `deprecation` suppression
|
||||
only inside adoption implementation/configuration. Keep the application-core architecture contract
|
||||
requiring `forRemoval=true` only for `ObjectStoragePort` and `StoredObject`. Keep the adapter-owned
|
||||
`ObjectStorageSettings` marker and add its lifecycle assertion in the object-storage leaf.
|
||||
|
||||
This keeps migration debt visible without falsely claiming that the migration mechanism itself is
|
||||
ready for removal.
|
||||
|
||||
### 5. Test/static-analysis/output cleanup
|
||||
|
||||
#### SpotBugs
|
||||
|
||||
Reuse one static `SecureRandom` in `RedisPrimitiveRuntimeServiceTest` rather than constructing a
|
||||
one-shot generator. After all test reports are clean, make every ordinary and custom test-source
|
||||
SpotBugs task included by `check` blocking. SpotBugs analysis errors and missing classes remain
|
||||
separately fail-closed.
|
||||
|
||||
#### Intentional architecture fixtures
|
||||
|
||||
Keep prohibited `BigDecimal(double/float)` constructor bytecode and apply method-only
|
||||
`BigDecimalLiteralDouble` suppressions. Fix unrelated warnings in allowed fixtures normally. A
|
||||
suppression must never replace the forbidden operation the ArchUnit test is supposed to detect.
|
||||
|
||||
#### Redis lab expected failures
|
||||
|
||||
Change `assert_fails` to capture stdout/stderr per case, assert a non-zero exit and the exact expected
|
||||
diagnostic, reject extra lines, and print the capture only when the assertion fails. Do not redirect
|
||||
to `/dev/null` and do not silence the Gradle `Exec` task globally.
|
||||
|
||||
#### Mockito/CDS
|
||||
|
||||
Provide `mockito-core` to test JVMs as an explicit startup `-javaagent` through a relocatable Gradle
|
||||
argument provider. This removes reliance on Java 21+ runtime self-attachment. Add test-JVM-only
|
||||
`-Xshare:off` because Mockito's bootstrap append otherwise prints the harmless CDS warning. No
|
||||
production JVM argument changes.
|
||||
|
||||
#### Skips
|
||||
|
||||
Retain exactly these five intentional app-bootstrap contract skips:
|
||||
|
||||
- `emailNotificationAdapterRunsOnlyWhenConfigured()`;
|
||||
- `slackNotificationAdapterRunsOnlyWhenConfigured()`;
|
||||
- `redisCacheAdapterRunsOnlyWhenEnabled()`;
|
||||
- `messagingBrokerAdapterRunsOnlyWhenConfigured()`;
|
||||
- `DisabledOptionalAdapterFixture.wouldFailIfItEverRan()`.
|
||||
|
||||
Qualification tasks continue to require positive discovery, at least one executed test, zero skips,
|
||||
and fresh XML, so this policy cannot turn a selected qualification lane green without execution.
|
||||
Any additional skip, or any of these five moving outside its named optional-adapter contract, fails
|
||||
the inventory check.
|
||||
|
||||
### 6. Warning-zero enforcement
|
||||
|
||||
After all existing warnings are removed:
|
||||
|
||||
- configure every leaf `JavaCompile` task with `-Werror`, `-Xlint:deprecation`, and
|
||||
`-Xlint:unchecked` in the root build policy;
|
||||
- retain Error Prone on the same compile tasks so its warnings are promoted by `-Werror`;
|
||||
- remove the root `checkstyleTest`/`spotbugsTest` warning-only policy and the app-bootstrap
|
||||
`sampleOffTest`, `functionalTest`, and `conditionalTransportTest` Checkstyle/SpotBugs
|
||||
`ignoreFailures` overrides, making every such task included by `check` blocking;
|
||||
- retain exact suppression comments as the only approved exception mechanism;
|
||||
- run Gradle with `--warning-mode=fail` in the warning-clean CI lane so Gradle API deprecations also
|
||||
fail rather than print.
|
||||
|
||||
`quarantineTest` remains non-blocking by design. Protected AWS/Docker qualifications remain separate
|
||||
environment evidence and are not converted into local unit tests.
|
||||
|
||||
## File Ownership and Expected Change Groups
|
||||
|
||||
### Root build policy
|
||||
|
||||
- `src/build.gradle`
|
||||
- `src/gradle/test-jvm-agents.gradle`, defining the relocatable Mockito `-javaagent` argument
|
||||
provider and test-only `-Xshare:off` policy, applied once by the root build
|
||||
- `.github/workflows/ci-quality-gates.yml`, adding `--warning-mode=fail` to the blocking
|
||||
`quality-gates` Gradle invocation
|
||||
|
||||
### Production leaves
|
||||
|
||||
- `src/application-core`
|
||||
- `src/adapter/inbound/web`
|
||||
- `src/adapter/outbound/cache-redis`
|
||||
- `src/adapter/outbound/fileserver`
|
||||
- `src/adapter/outbound/httpclient`
|
||||
- `src/adapter/outbound/identifier`
|
||||
- `src/adapter/outbound/messaging`
|
||||
- `src/adapter/outbound/notification`
|
||||
- `src/adapter/outbound/objectstorage`
|
||||
- `src/adapter/outbound/persistence-jpa`
|
||||
- `src/app-bootstrap`
|
||||
- `src/sample-portfolio`
|
||||
- `src/shared-contract`
|
||||
|
||||
Every focused command is derived from the owning leaf's `gradle_path` in
|
||||
`src/config/architecture/modules.json`; no production dependency edge changes are permitted unless
|
||||
the registry is deliberately updated and its architecture verifier passes.
|
||||
|
||||
### Tests and shell contract
|
||||
|
||||
- owning leaf tests adjacent to every behavior change
|
||||
- exact architecture violation fixtures under app-bootstrap test sources
|
||||
- `infra/redis-lab/test/redis-lab-contract.sh`
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Add failing behavioral regressions for locale, ETag parsing, async exception observation,
|
||||
cleanup propagation, Redis descriptor identity, and retry-context isolation.
|
||||
2. Implement those behavior fixes and run owner-focused tests.
|
||||
3. Remove behavior-neutral compiler/Error Prone warnings per leaf, using only exact justified
|
||||
suppressions.
|
||||
4. Migrate Jackson, Lettuce, AWS SDK, Testcontainers, URL, and generic stubs; run their focused
|
||||
behavior/qualification tests.
|
||||
5. Correct legacy lifecycle markers and exact compatibility suppressions; run application-core,
|
||||
object-storage, sample, and architecture contracts.
|
||||
6. Clean test-only warnings, SpotBugs, Mockito/CDS, and Redis-lab output.
|
||||
7. Enable blocking compiler, Checkstyle, SpotBugs, and Gradle warning gates.
|
||||
8. Run focused checks, architecture validators, dependency locks, full tests, full check, and the
|
||||
fresh warning-clean build.
|
||||
9. Update the LLM Wiki branch note and the warning-debt error note with resolved evidence or exact
|
||||
remaining environmental blockers.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Focused verification
|
||||
|
||||
- Each behavior change follows RED → GREEN with the owning leaf test.
|
||||
- Static-only warning fixes use the exact `compileJava`, `compileTestJava`, Checkstyle, or SpotBugs
|
||||
task as the failing/passing executable contract.
|
||||
- Third-party API migrations run behavior tests that exercise request mapping, retry/backoff,
|
||||
sorted-set bounds, schema parsing, or network-fault cut/restore semantics.
|
||||
- Legacy suppressions are checked by architecture tests that reject old imports outside the named
|
||||
compatibility surface.
|
||||
|
||||
### Repository verification
|
||||
|
||||
Run from `src/`:
|
||||
|
||||
```bash
|
||||
./gradlew test --no-daemon --console=plain
|
||||
./gradlew check --no-daemon --console=plain
|
||||
./gradlew build --warning-mode=fail --no-daemon --console=plain
|
||||
./gradlew clean build --warning-mode=all --no-daemon --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership \
|
||||
verifyDependencyLocks verifyPublicPathSnapshot verifyEnvKeys \
|
||||
--no-daemon --console=plain
|
||||
```
|
||||
|
||||
Also verify the real gate matrix, wrapper contract, shell syntax, warning-report XML, skipped-test
|
||||
inventory, and `git diff --check`.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If a suggested warning fix changes a public signature or weakens an identity/security invariant,
|
||||
retain the behavior and use an exact documented suppression backed by a regression.
|
||||
- If three attempted fixes in one warning family fail or expose cross-module coupling, stop that
|
||||
family and revisit the design instead of stacking suppressions.
|
||||
- If the AWS retry or Toxiproxy migration cannot reproduce old behavior, report that qualification
|
||||
as blocked; do not claim warning-zero by suppressing the deprecation.
|
||||
- If a warning originates only in generated code, prove the generated source owner and configure
|
||||
that exact generated boundary; do not disable warnings for handwritten sources.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- **ETag grammar regression:** use quote-aware focused tests before replacing the parser.
|
||||
- **Authorization drift:** test role normalization under Turkish locale.
|
||||
- **Redis catalog weakening:** retain identity comparison and test cross-catalog rejection.
|
||||
- **AWS retry semantic drift:** assert maximum attempts and backoff classes/policies, then run the
|
||||
object-storage provider tests.
|
||||
- **Legacy data stranding:** preserve legacy activation and characterization until the separate
|
||||
data/API migration gates are approved.
|
||||
- **Hidden diagnostics:** capture-and-assert expected stderr; never discard it.
|
||||
- **Suppression creep:** exact annotations plus architecture/import checks prevent module-wide
|
||||
exemptions.
|
||||
- **Build duration:** use owner-focused RED/GREEN loops and reserve full clean builds for integration
|
||||
checkpoints and final proof.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The work is complete only when:
|
||||
|
||||
1. All behavior regressions and focused owner checks pass.
|
||||
2. Every compiler task passes with `-Werror`, deprecation lint, unchecked lint, and Error Prone.
|
||||
3. Every ordinary/custom Checkstyle and SpotBugs task included by `check` is blocking and clean.
|
||||
4. Legacy warnings are limited to no output because exact compatibility code is explicitly and
|
||||
locally justified; no module/task-wide suppression exists.
|
||||
5. The Redis lab successful contract prints only its success summary and unexpected child
|
||||
diagnostics still fail the test with captured evidence.
|
||||
6. Test JVMs print no CDS/self-attachment warning.
|
||||
7. Full test, check, build, dependency, architecture, runtime-membership, env, public-path, wrapper,
|
||||
gate-matrix, shell, and diff validators pass.
|
||||
8. The final fresh clean-build log contains no `warning:`, deprecated/unchecked `Note:`, SpotBugs
|
||||
non-zero message, OpenJDK warning, or leaked expected-negative Redis diagnostic.
|
||||
9. LLM Wiki capture records commands, results, resolved warning counts, suppressions, and any
|
||||
environment-only qualification not executed locally.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Web Security Boundary Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** approved by the user's instruction to apply the reviewed P1/P2 work sequentially
|
||||
**Scope:** JWT/OIDC/JWKS and CORS behavior at the `adapter:inbound:web` Spring Security filter boundary
|
||||
|
||||
## Context
|
||||
|
||||
The module has unit contracts for JWT validators, exception classification, envelope writers, and
|
||||
CORS settings. It does not yet prove that a real bearer request crosses issuer discovery, JWKS
|
||||
retrieval, signature/claim validation, principal conversion, `SecurityFilterChain`, and the public
|
||||
error envelope. CORS configuration is likewise untested at the filter boundary, where preflight
|
||||
ordering relative to authentication is the important behavior.
|
||||
|
||||
These are release-boundary checks and must not silently skip because an external IdP, environment
|
||||
variable, or optional flag is absent.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a dedicated `webSecurityBoundaryTest` task that reuses the ordinary test output/classpath and
|
||||
runs only JUnit tests tagged `security-boundary`. Ordinary `test` excludes that tag so each contract
|
||||
runs once. The dedicated task:
|
||||
|
||||
- fails when no tests are discovered;
|
||||
- disables up-to-date reuse;
|
||||
- fails the root suite when any test reports `SKIPPED`;
|
||||
- is required by the inbound-web `check` task;
|
||||
- uses UTC and no environment-dependent conditions or assumptions.
|
||||
|
||||
JWT tests use a JDK loopback `HttpServer` bound to `127.0.0.1` on an ephemeral port. It serves the
|
||||
minimum OIDC discovery document and JWKS response. Tests generate ephemeral RSA keys and compact
|
||||
RS256 JWTs with the already-resolved Nimbus dependency; no new library or external network is
|
||||
allowed. Each failure case uses a fresh server and Spring context to prevent decoder/JWK cache
|
||||
cross-contamination.
|
||||
|
||||
CORS tests build the production `SecurityConfig` and real `springSecurityFilterChain` with direct
|
||||
configuration properties. They issue real preflight and actual-origin MockMvc requests. A test JWT
|
||||
decoder bean is allowed here because CORS ordering—not token decoding—is the owned boundary.
|
||||
|
||||
## JWT/JWKS Contract
|
||||
|
||||
- application context startup performs zero discovery/JWKS calls (lazy decoder);
|
||||
- a correctly signed token reaches a protected controller and exposes the expected
|
||||
`AuthenticatedPrincipal` subject/roles;
|
||||
- expiry beyond the configured 60-second skew, issuer mismatch, audience mismatch, wrong
|
||||
signature, and unknown `kid` produce their exact stable 401 error codes and bounded
|
||||
`WWW-Authenticate`/`Retry-After` headers;
|
||||
- deterministic JWKS 503 produces `AUTH_JWKS_UNAVAILABLE`, HTTP 503, and `Retry-After: 30`;
|
||||
- after that first-request 503, the same lazy decoder/context retries initialization and succeeds
|
||||
once the JWKS endpoint recovers;
|
||||
- discovery metadata that is fetched successfully but is internally inconsistent produces the
|
||||
fixed 500 `INTERNAL_AUTH_MISCONFIGURATION` envelope rather than a raw initialization exception;
|
||||
- responses never contain the bearer token, issuer URL, `kid`, JWK material, or internal decoder
|
||||
diagnostics.
|
||||
|
||||
## CORS Contract
|
||||
|
||||
- an approved credentialed preflight to an authenticated endpoint succeeds before bearer
|
||||
authentication and emits exact origin/credentials/method/header/max-age policy;
|
||||
- an unapproved origin receives 403 without allow-origin or allow-credentials reflection;
|
||||
- disabled CORS emits no CORS response headers;
|
||||
- wildcard origin without credentials returns `*` and no credentials header;
|
||||
- an approved actual-origin request receives matching CORS and bounded `Vary` headers;
|
||||
- wildcard plus credentials remains a settings startup failure (already covered by settings tests).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- external IdP/TLS/rotation rehearsal;
|
||||
- browser-engine SameSite behavior;
|
||||
- Redis-backed session continuity (the next P1 batch);
|
||||
- new test libraries, Docker, or changes to production dependency direction;
|
||||
- staging, commit, amend, or push by an agent.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user