The review found one defect shape repeated across the platform: surfaces that were declared, bound, and documented, but that nothing read. An operator configuring fullUrlRecording, bodyLogging, retry.policy, validatedDnsPinning, timeout.dns, or any of ten declared metric names got a guarantee the code never delivered. Every such surface is now in exactly one of three states -- wired for real, rejected at startup, or registered in a test-enforced gap list with its reason. No silent no-ops remain. P0: - Activate the platform from bootstrap behind app.httpclient.enabled, with a single auto-configuration importing the nine child configurations. - Give the platform a strict, repository-level ENV contract: 74 leaf fields derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected. - Route typed HTTP service clients through the call kernel via KernelHttpExchangeAdapter, so they stop bypassing platform policy. - Pin dynamic-target DNS resolution to the socket for the life of a call, closing the resolve-then-connect TOCTOU / rebinding window. - Actually transmit the idempotency key, and make retry eligibility depend on transmission rather than on merely holding one. - Reject reactive authentication and reactive redirect at startup instead of declaring support that does not function. - Fix the Reactor-only Stable contract row so the lane stops failing. - Stop advertising HTTP/3 on a transport that negotiates HTTP/1. P1 covers execution and retry accounting, redirect security (per-hop target guarding, sensitive-header stripping, 303 body handling), runtime rotation and transport resource ownership keyed by generation, dynamic-target hardening (subdomain matching, global-unicast classification, strict CIDR parsing), protocol intent, pool and timeout wiring, streaming and body limits, observability parity, and OAuth single-flight refresh on a bounded pool with a bounded wait. P2 covers configuration and documentation drift, the Gradle check wiring for the four hermetic lanes, and the CI gate matrix. Two test-quality defects surfaced while closing these: the HTTP/2 stream saturation test ran against cleartext HTTP/1.1 while asserting nothing about the protocol, and an OAuth contention test slept on a latch that could fire before the callers it meant to observe. Both now assert what their names claim. Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check (checkstyle, spotless, spotbugs, and the four hermetic lanes), verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile, verifyDependencyLocks, the documentation and gate-matrix verifiers, and the performance lane against a real TLS+ALPN HTTP/2 server. Not executed, and tracked rather than claimed: Docker/Toxiproxy fault injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2 distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS integration, and a whole-repository check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
21 KiB
Redis Optionality and Composition 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 Redis genuinely optional at both ends — APP_REDIS_ENABLED=false loads, binds,
validates and allocates nothing Redis-shaped, and APP_REDIS_ENABLED=true assembles a validated,
fail-fast Redis runtime — and close the SDK correctness defects that must not be wired live.
Architecture: A single conditional composition root (RedisSdkAutoConfiguration) owns
RedisSdkSettings, its validation, its secret/credential resolution, and its resource loading.
Nothing Redis-shaped is registered by the global @ConfigurationPropertiesScan. Secret requirements
move from the unconditional bootstrap list into that conditional owner. The SDK stays an
implementation detail of the adapter:outbound:cache-redis leaf; provider-neutral semantic ports
are re-implemented on top of it in a later phase.
Tech Stack: Java 21, Spring Boot 4.0.0, Lettuce, Gradle (fail-closed 19-leaf registry), JUnit 5, AssertJ, ArchUnit.
Status — 2026-08-10
| Review item | State | Where |
|---|---|---|
| P1 #1 optionality (settings/validation half) | done | RedisSdkAutoConfiguration, RedisSdkSettings, RedisOptionalityContractTest |
| P1 #1 optionality (client/runtime half) | done | Phase D: RedisTopologyClientFactory, RedisRuntimeOwner, RedisStartupProbe, health contributors |
| P1 #2 production Redis secrets | done | SecretSourceValidator, RedisActivationValidator |
| P1 #3 env SSOT for the 34 settings | done | env-keys.yaml, verifyEnvKeys check E |
| P1 #4 semantic adapters | 4 of 5 | rate-limit, lease, idempotency V2, cache done. Session is blocked, not deferred: no provider-neutral session contract exists in application-core or shared-contract — it was deleted with the previous generation and the bootstrap references it only by bean name. Restoring it is a contract design task, not a port implementation, and the review does not specify that contract. |
| P1 #5 counter TTL | done | AtomicCounterScripts |
| P1 #6 transaction slot (aggregate check) | done | LettuceRedisTransactionOperations.AttemptSlot |
| P1 #6 transaction exclusive connection lease | done | typed RedisLease with invalidate(); the TRANSACTION lane is bounded and a poisoned connection is never pooled |
| P1 #7 telemetry isolation | done | NoThrowObservationSink, all three executors |
| P1 #8 topology lane fail-closed | done | cache-redis/build.gradle |
| P1 #9 README three-state split | done | cache-redis/README.md |
| TLS lane | done | infra/redis-sdk/tls/compose.yml, plaintext port off, certificates generated at start-up |
| P1 #9 PR/nightly/RC release gates | done | redis-sdk-topology.yml PR/schedule/RC matrix + evidence artifacts; gate promoted from delegated-pending |
| P1 #10 Netty floor | done | ext['netty.version'] = '4.2.17.Final', all lockfiles |
| Phase B3 orphan configuration removal | done | 4 blocks removed from application.yml, 33 .env keys dropped, registry rows deprecated |
P2/P3 hardening
| Item | State | Where |
|---|---|---|
| Multi-key permit dead branch | done | CommandPolicyGuard.requirePermits; set algebra and blocking list now present a multi-key permit |
| Codec type safety | done | RedisCodecRegistry records the declared type and refuses a mismatched lookup |
| Error metadata on decode failure | done | RedisFailureMetadata.storedDataCorruption, deployment mode threaded from the caller |
| Pub/Sub codec per target | done | per-channel codec map; pattern subscriptions must agree on one codec |
| Pub/Sub backpressure | done | SubscriptionFlux bounded buffer + explicit overflow policy, decode failure terminates |
Admin CONFIG GET |
done | fixed allowlisted projection, secret-shaped values redacted, no caller pattern |
| Reply budget | done (consolidated) | dead CommandPolicyGuard.validateReply removed; RedisOperationContext.requireReplyWithinBudget is the single authority |
| Sentinel durability probe | done | min-replicas-max-lag now required alongside the replica count |
| Missing raw allowlist resource | done | RedisSdkAutoConfiguration opens it at startup |
| ACL fixture | done | user default off, fixture-only header, named-credential instructions |
| Readiness false-green | done | validate-group-membership: true, group names only contributors that exist |
| Dependency drift | done | unused spring-data-redis/micrometer-core removed, Reactor declared directly |
| JSON framing | done | control characters escaped, schema identifier constrained by regex |
| Connection lifecycle state machine | done | RedisRuntimeOwner OPEN→DRAINING→CLOSED |
Gateway/CommandRequest visibility |
open | needs sdk.programmability, sdk.raw, sdk.admin and sdk.extensions to stop constructing requests directly; a package restructuring, not a rename |
Raw movable keys (SORT BY/GET/STORE) |
done | RawMovableKeys settles SORT/SORT_RO locally including the STORE destination; BY/GET stay refused because their patterns cannot be namespace-checked, and an unknown option is a rejection rather than a guess |
| Batch observed-aggregate reply bytes | done | BatchExecution accumulates measured replies and fails the item that crosses the ceiling |
Residual limitation on P1 #6: keys queued inside the callback are only knowable after MULTI, so
the aggregate slot is enforced as each key becomes known — the offending command is refused before
it is written and the window is discarded, rather than the whole attempt being refused before
WATCH. Refusing before WATCH in every case needs a declared-keys transaction API, which Phase E
would revisit anyway.
Global Constraints
- Registry SSOT for module identity, Gradle paths and allowed edges is
src/config/architecture/modules.json. Never infer a Gradle path. - Commit policy is
human-only. Agents do not stage, commit, amend, or push. domain-coremust stay free of framework/transport/database/cloud dependencies.application-coremust never see an SDK type, a Redis key, a topology or a connection type.- Global Redis activation is exactly one switch:
APP_REDIS_ENABLED.APP_CACHE_REDIS_ENABLEDmust not be a second master switch. - Every new
APP_*key must land in all four places orverifyEnvKeysfails:src/app-bootstrap/src/main/resources/application.yml,src/.env,docs/registries/env-keys.yaml, and (when secret-classified)docs/registries/secrets-classification.yaml. SecretsClassificationRegistryTestassertsSecretSourceValidator.REQUIRED_PROD_SECRETSmatchesdocs/registries/secrets-classification.yaml1:1. Changing one requires changing the other.- Netty floor:
4.2.16or higher (CVE-2026-42577 epoll<4.2.13, CVE-2026-59901 codec-compression<4.2.16). - Topology lane modes allowlist: exactly
STANDALONE,SENTINEL,CLUSTER. - Verification commands run from
src/.
Current-state facts this plan is written against
Established by direct inspection on 2026-08-10, working tree (not HEAD):
CaSkeletonApplicationscansdev.caskeleton.adapterfor@ConfigurationProperties, soRedisSdkSettings(ca-skeleton.capabilities.redis-sdk) is registered with Redis off.RedisSdkSettings.validate()has no production caller.- The
cache-redisleaf has no@Bean,@Configuration, or@AutoConfigurationin main source: nothing constructs a client, connection, gateway, or health contributor. - 240 tracked main-source files under
cache-redisare deleted in the working tree; the SDK (~300 files under…cache.redis.sdk) is untracked. The semantic cache/session/idempotency/ rate-limit/lease adapters are gone. ca-skeleton.providers.redis.*,ca-skeleton.capabilities.cache.*, andca-skeleton.security.redis-session.*inapplication.ymlbind to no Java type — orphan configuration from the previous generation.SecretSourceValidator.REQUIRED_PROD_SECRETSrequiresAPP_CACHE_REDIS_PASSWORDandAPP_CACHE_REDIS_KEY_HMAC_SECRETunconditionally in prod; the other Redis roles have conditional skips.verifyEnvKeyscompares only the three text sets (.env,application.ymlplaceholders,env-keys.yaml); it never readsspring-configuration-metadata.json, so a typed property with no env name passes.redisTopologyTestbuilds its tag aslane-${declaredMode}from an unvalidated project property, with no mode allowlist and no positive test-count postcondition — an unknown mode selects zero tests and exits 0.src/app-bootstrap/gradle.lockfilepinsio.netty:*:4.2.7.FinalonproductionRuntimeClasspath, and still carries aredisCompositionTestRuntimeClasspathconfiguration whose source set no longer exists.
Phase A — Redis optionality (P1 #1, #2) and the dead second switch
Task A1: Remove the unconditional production Redis secret requirement
Files:
- Modify:
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java - Modify:
docs/registries/secrets-classification.yaml - Test:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java
Interfaces:
-
Produces:
SecretSourceValidator.REQUIRED_PROD_SECRETSwithout anyAPP_CACHE_REDIS_*entry;isCacheRedisMaterial(String)+isRedisGloballyEnabled()private helpers gating every remaining Redis-prefixed secret onapp.redis.enabled. -
Step 1: Write the failing test — prod profile, Redis off, no Redis secrets present, validator must not throw.
-
Step 2: Run it and watch it fail on the two cache secrets.
-
Step 3: Gate every Redis secret on
app.redis.enabledplus its role selector. -
Step 4: Re-run the focused test class.
-
Step 5: Update
secrets-classification.yamlrequired_in_prodmetadata to match.
Task A2: Stop the global scan from registering RedisSdkSettings
Files:
- Modify:
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java(exclude the SDK config package) or moveRedisSdkSettingsout of a scanned package — preferred: keep the class where it is and drop@ConfigurationPropertiesfrom it, binding it instead from the conditional configuration with@ConfigurationPropertieson the@Beanmethod. - Test: new bootstrap contract test asserting zero
RedisSdkSettingsbeans whenapp.redis.enabledis absent or false.
Task A3: RedisSdkAutoConfiguration — the ON/OFF composition root
Files:
- Create:
src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java - Create:
src/adapter/outbound/cache-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports - Test:
…/sdk/config/RedisSdkAutoConfigurationTest.java(ApplicationContextRunner)
Conditions: @ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true").
Inside: bind settings, call validate() and fail the context on IllegalStateException, log
warnings, then (Phase D) build the topology client.
Task A4: Retire APP_CACHE_REDIS_ENABLED as a second master switch
Files:
- Modify:
src/app-bootstrap/src/main/resources/application.yml(addapp.redis.enabled) - Modify:
src/.env,docs/registries/env-keys.yaml
Phase B — env SSOT migration (P1 #3)
Task B1: Register APP_REDIS_ENABLED and the 34 SDK settings
Names are fixed by the review's env contract table. Each env-keys.yaml row carries
property, owner_module, type, default, secret, required_when, and (where one exists)
deprecated_alias + removal_deadline.
Task B2: Extend verifyEnvKeys to read spring-configuration-metadata.json
Bidirectional: a typed app.redis.* property with no registry row fails; a registry row whose
property matches no metadata entry fails.
Task B3: Remove the orphan generations
Delete ca-skeleton.providers.redis.*, ca-skeleton.capabilities.cache.*, and
ca-skeleton.security.redis-session.* from application.yml once a migration table records the
old→new mapping; drop the now-orphaned .env keys; mark the registry rows deprecated rather than
deleting their metadata.
Phase C — SDK correctness (P1 #5, #6, #7)
Task C1: Atomic counter must not add a TTL to a pre-existing persistent key
Files:
- Modify:
…/sdk/lettuce/operations/AtomicCounterScripts.java - Test:
…/sdk/lettuce/operations/AtomicCounterScriptsTest.java
Both scripts must record existence before the increment and apply the initial expiry only when the key was absent:
local existed = redis.call('EXISTS', KEYS[1])
local value = redis.call('INCRBY', KEYS[1], ARGV[1])
if existed == 0 then
if ARGV[3] == 'AT' then
redis.call('PEXPIREAT', KEYS[1], ARGV[2])
else
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
end
return value
Task C2: Validate the transaction's whole key set against one slot
Files:
- Modify:
…/sdk/programmability/LettuceRedisTransactionOperations.java - Test:
…/sdk/programmability/LettuceRedisTransactionOperationsTest.java
Collect watched + queued keys per attempt and validate the aggregate slot before MULTI, instead
of validating the WATCH bundle and each queued write independently.
Task C3: A throwing observation sink must not fail a successful command
Files:
- Create:
…/sdk/lettuce/observability/NoThrowObservationSink.java - Modify:
…/sdk/lettuce/command/SyncRedisCommandExecutor.java - Modify:
…/sdk/lettuce/command/ReactiveRedisCommandExecutor.java - Test:
…/sdk/lettuce/command/ObservationIsolationTest.java
Phase D — Runtime composition (P1 #4 prerequisite, deferred)
Topology strategy (standalone/sentinel/cluster), authentication/TLS, shared vs dedicated connection lanes, lifecycle owner, capability/durability probe, health contributors.
Phase E — Semantic adapter restoration (P1 #4, deferred)
Re-implement the provider-neutral ports on top of the SDK: cache, session, idempotency V2, rate-limit, efficiency-only lease. This is the restoration of the 240 deleted files' behaviour and is the largest single body of work in this plan.
Phase F — Release gates, evidence and dependencies (P1 #8, #9, #10)
Task F1: redisTopologyTest fails closed
Mode allowlist, failOnNoDiscoveredTests = true, per-lane required tag/class presence, and a
>= 1 executed-test postcondition.
Task F2: Netty floor 4.2.16
Add a platform constraint, regenerate every lockfile, rerun the dependency scan.
Task F3: README status split
API implemented / Spring composition implemented / production-qualified as three separate
states.
Phase G — P2/P3 hardening (deferred)
Gateway/request visibility, multi-key permit dead branch, connection lifecycle state machine,
reply budgets, admin CONFIG GET projection, pub/sub codec mapping and backpressure, codec type
safety, error metadata, raw movable keys, Sentinel durability probe, ACL fixture, readiness
false-green, missing raw resource, dependency drift, JSON framing.
Round 2 — the defects a real server found that this plan did not
Everything above was written before any of it had run against Redis. A second review started four Docker lanes, wired the production code to them, and found that several items marked done were done in the sense that the code existed, not in the sense that it worked. What follows is what that round changed, and what it changed because of.
The readiness group could not start at all
management.endpoint.health.group.readiness.include named redisRequired, a contributor that only
exists when a correctness role selected Redis. Boot validates group membership and does not
tolerate a conditional member being absent, so every Redis-off and cache-only deployment failed at
startup with Included health contributor 'redisRequired' in group 'readiness' does not exist. The
comment in application.yml asserted the opposite.
The group now names only unconditional contributors, and
RedisReadinessGroupPostProcessor appends redisRequired from RedisCorrectnessRoles — the same
predicate the bean's @Conditional asks, so membership and existence cannot drift.
RedisReadinessGroupPostProcessorTest boots a real Actuator context in each of the three shapes;
putting the name back in the shipped file makes two of them fail exactly as production did.
Redis on composed no capability
APP_REDIS_ENABLED=true produced a client, an owner and a health contributor. Every semantic port
count was zero, so a deployment that selected redis for its rate limiter started, reported
healthy, and had no rate limiter. RedisCapabilityConfig composes cache, rate limit, lease and the
owner-safe idempotency store, each on its own selector.
The idempotency guard was also counting application.idempotency.IdempotencyStorePortV2, which no
provider implements — the implemented contract is the one in …idempotency.v2. Selecting redis
therefore required a bean nothing could supply. Driving the V2 store from an executor remains
outstanding and is named as such rather than covered by a guard that cannot see it.
Four key prefixes, and an ACL that matched none of them
Each capability joined its own namespace-application / namespace-environment pair in its own
order, so the cache wrote ca-skeleton:prod:… while the ACL granted ~prod:*. CapabilityKeyspace
renders every capability below one RedisNamespace, and the per-capability namespace keys are
deprecated.
The scripted capabilities also ran EVALSHA on the application account, which does not have it.
Lanes now carry a RedisCredentialRole; the topology factory builds one client per configured
role, so the SCRIPT lane authenticates as the advanced account and the account that reads a cache
entry still cannot execute a script. LiveRedisSemanticPortsTest proves both directions against a
real server.
Cluster transactions were impossible, and multi-key WATCH was refused
beginTransaction() on a live cluster failed by design: every lane opened the slot-routing
connection, which cannot own a window. RedisTransactionRunner derives a routing key and pins the
lane to the node that owns the slot. Fixing that surfaced a second defect a cluster was not needed
for — watch() presented no multi-key permit, so watching more than one key was rejected
unconditionally, which is most optimistic transactions.
The fixtures could not fail
Every ACL account was nopass, which accepts any password: every assertion about authentication
passed for the same reason a typo would have. The accounts carry real passwords and a wrong one is
now asserted to produce WRONGPASS. The cluster lane's readiness helper checked
CLUSTER INFO unauthenticated, so it never matched, never exited, and up --wait returned while
slots were still being assigned; a ready gate now blocks on cluster_state:ok.
TLS was reachable only by hand
tls is a lane of redisTopologyTest and of the CI matrix. Trust material resolved with
new File(...) broke classpath: references, and resolving it purely through the resource loader
breaks mounted paths — both shapes are ordinary, and both are supported.
Gates that could report success for a lane they did not run
afterTest fires for skipped tests too, so the "ran something" check could be satisfied by a run
that skipped everything. Lanes now declare the classes they exist to run and a floor for the
executed count, and a skipped test fails the run. verifyEnvKeys gained a check for registered
keys that nothing reads — no typed property, no yaml reference, no .env entry, no Java consumer —
which found eight orphaned Redis keys beyond the two the review named.
Verified
| Lane | Result |
|---|---|
| standalone | 25 tests |
| sentinel | 27 tests |
| cluster | 29 tests, including a same-slot transaction and a cross-slot refusal |
| tls | 4 tests, filesystem and classpath CA |
Repository: 3594 tests, 0 failures. verifyCleanArchitectureDependencies,
verifyPublicPathSnapshot, verifyEnvKeys, CleanArchitectureTest, verify-gate-matrix.sh
(37 gates) and verify-gradle-wrapper.sh all pass.
Still open
- Session port. No provider-neutral session contract exists in
application-coreorshared-contract; it went with the previous generation. That is a contract to design, not a port to implement, and inventing one here would be guessing at its shape. - V2 idempotency executor.
IdempotencyExecutorV2targets a contract no provider implements. - Gateway /
CommandRequestvisibility. Narrowing it is a package restructuring acrosssdk.programmability,sdk.raw,sdk.adminandsdk.extensions, not an access-modifier change.