feat(httpclient): close the platform review's P0/P1/P2 findings
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f10b791d3
commit
0cd959a494
@@ -0,0 +1,228 @@
|
||||
# HTTP Client Platform — Configuration Reference
|
||||
|
||||
Every outbound call resolves exactly one **Named Client Profile**. The whole capability lives under
|
||||
the `app.httpclient` prefix: profiles under `app.httpclient.clients[N]`, Dynamic Target policies
|
||||
under `app.httpclient.dynamic-targets[N]`.
|
||||
|
||||
Design §30.1 forbids a production profile from inheriting large framework defaults. Anything a
|
||||
production deployment must decide has either no default or an unusable one, and
|
||||
`HttpClientStartupValidator` fails the context rather than guessing.
|
||||
|
||||
## The master switch
|
||||
|
||||
| Property | Type | Default | Environment |
|
||||
|---|---|---|---|
|
||||
| `app.httpclient.enabled` | boolean | `false` | `APP_HTTPCLIENT_ENABLED` |
|
||||
|
||||
Off is the shipped state and it is a structural one. `HttpClientPlatformAutoConfiguration` lives in
|
||||
a package the composition root's component scan excludes, so while the switch is absent or false the
|
||||
class is never processed and neither is anything it imports: no property is bound, and no transport
|
||||
provider, connection pool, TLS context, credential, thread, gateway or actuator endpoint exists. A
|
||||
malformed HTTP client setting cannot fail the startup of a deployment that never wanted outbound
|
||||
HTTP.
|
||||
|
||||
Anything that is not exactly `true` — `yes`, `1`, blank — leaves the platform off. Turning it on
|
||||
with no client declared is a startup failure carrying `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`: a
|
||||
platform with nothing to call still holds transport providers and gateways no caller can reach.
|
||||
|
||||
## Declaring clients from the environment
|
||||
|
||||
Clients are an indexed list carrying their own `name`, not a map keyed by name. A map key becomes a
|
||||
segment of the environment variable and the relaxed binder normalises it, so `payment-api` and
|
||||
`payment_api` would arrive as one entry with nothing said about the one that was lost. Both a
|
||||
duplicate name and a name that collides once normalised fail startup.
|
||||
|
||||
```dotenv
|
||||
APP_HTTPCLIENT_ENABLED=true
|
||||
|
||||
APP_HTTPCLIENT_CLIENTS_0_NAME=payment
|
||||
APP_HTTPCLIENT_CLIENTS_0_BASE_URL=https://payment.example
|
||||
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0=payment.example
|
||||
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0=443
|
||||
APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES=1048576
|
||||
APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID=payment
|
||||
|
||||
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME=webhook
|
||||
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0=https
|
||||
```
|
||||
|
||||
`docs/httpclient/env-fields.yaml` is the registry of accepted variable names. It is
|
||||
derived from the settings record and held to it in both directions, and the platform refuses to
|
||||
start on an `APP_HTTPCLIENT_` variable that is not in it — so
|
||||
`APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL` fails startup instead of silently leaving the client
|
||||
on its default budget. Unknown keys supplied through a configuration file rather than the
|
||||
environment are refused by strict binding for the same reason.
|
||||
|
||||
Only `APP_HTTPCLIENT_ENABLED` appears in `src/.env` and `docs/registries/env-keys.yaml`. It is the
|
||||
one key with a deployment-independent value; templating an indexed client in `application.yml` would
|
||||
materialise a nameless client in every deployment, which the aggregate validation refuses.
|
||||
|
||||
## `app.httpclient.clients[N]`
|
||||
|
||||
| Property | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | — | Required, unique, and distinct from every other name once normalised for the environment |
|
||||
| `mode` | `TRUSTED` \| `DYNAMIC` | `TRUSTED` | A dynamic profile may not carry a default credential |
|
||||
| `base-url` | URI | — | Required for a trusted profile; no userinfo, no query |
|
||||
| `allowed-hosts` | list | empty | Required in production |
|
||||
| `allowed-ports` | list | empty | Compared against the effective port |
|
||||
| `api` | `REST_CLIENT` \| `WEB_CLIENT` | `REST_CLIENT` | Decides blocking or reactive runtime |
|
||||
| `transport` | `APACHE` \| `JDK` \| `REACTOR_NETTY` \| `JETTY` \| `SIMPLE` | `APACHE` | `SIMPLE` is rejected in production |
|
||||
| `protocols` | list | `HTTP_1_1` | The default transport is Apache, whose classic client is HTTP/1.1 only; a profile that wants HTTP/2 declares it together with a transport that can deliver it. `HTTP_3` requires the experimental acknowledgement |
|
||||
| `experimental-acknowledgement` | string | — | Must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||
|
||||
### `pool`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `max-total-connections` | `50` | Socket ceiling for the runtime |
|
||||
| `max-connections-per-route` | `25` | Per-upstream ceiling |
|
||||
| `max-pending-acquires` | `100` | Waiting-request memory ceiling |
|
||||
| `pending-acquire-timeout` | `200ms` | Pool or stream wait ceiling |
|
||||
| `max-idle-time` | `30s` | Idle eviction |
|
||||
| `max-life-time` | `5m` | Picks up DNS, load-balancer, and certificate changes |
|
||||
| `validate-after-inactivity` | `5s` | Stale and half-open detection |
|
||||
| `eviction-interval` | `15s` | Background cleanup |
|
||||
| `shutdown-timeout` | `5s` | Drain deadline before forced close |
|
||||
| `requires-route-pool` | `false` | Set when route-scoped limits are mandatory; the JDK transport then refuses the profile |
|
||||
| `requires-bounded-pending-queue` | `false` | Same, for a bounded pending queue |
|
||||
|
||||
### `timeout`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `dns` | `300ms` | Hostname resolution |
|
||||
| `connect` | `500ms` | Socket connect |
|
||||
| `tls-handshake` | `1s` | TLS and ALPN |
|
||||
| `proxy-connect` | `500ms` | Proxy socket or CONNECT |
|
||||
| `request-write-idle` | `1s` | No progress writing the request |
|
||||
| `response-header` | `2s` | Until final response headers |
|
||||
| `read-idle` | `3s` | Between response chunks |
|
||||
| `total-call` | `4s` | The whole logical call, including retry backoff |
|
||||
| `streaming-idle` | `30s` | Silence on a long-lived stream |
|
||||
|
||||
`total-call` must not be shorter than `connect` or `response-header`; the validator emits
|
||||
`INVALID_TIMEOUT_BUDGET` otherwise.
|
||||
|
||||
### `redirect`, `request`, `response`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `redirect.enabled` | `false` | Engine redirect handling is always off; the platform follows hops itself |
|
||||
| `redirect.max-hops` | `0` | Enabling redirects with zero hops is a configuration error |
|
||||
| `redirect.allow-cross-origin` | `false` | When enabled, credentials are stripped on the hop |
|
||||
| `request.max-body-bytes` | `0` | Required in production |
|
||||
| `request.compression` | `false` | |
|
||||
| `response.max-wire-bytes` | `5242880` | Bytes on the wire |
|
||||
| `response.max-decoded-bytes` | `10485760` | Bytes after decoding; hard maximum is 64 MiB |
|
||||
| `response.allowed-content-types` | JSON + problem+json | Empty means "any" |
|
||||
|
||||
### `authentication`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `type` | `NONE` | One of the design §20.1 methods |
|
||||
| `registration-id` | — | Required for OAuth2 |
|
||||
| `scopes` | empty | Part of the token cache key |
|
||||
| `audience` | — | Part of the token cache key |
|
||||
| `header-name` | — | Required for `API_KEY_HEADER`; must be on the allowlist |
|
||||
| `secret-reference` | — | Resolved by the deployment's secret loader, never a literal |
|
||||
|
||||
### `retry`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `policy` | `none` | Named policy for reporting |
|
||||
| `max-attempts` | `1` | Attempts, not retries |
|
||||
| `base-backoff` | `50ms` | |
|
||||
| `max-backoff` | `200ms` | |
|
||||
| `jitter` | `FULL` | `NONE` \| `FULL` \| `DECORRELATED` |
|
||||
| `retry-after` | `HONOR` | `HONOR` \| `IGNORE` \| `CAP` |
|
||||
| `budget` | — | Shared token bucket name |
|
||||
|
||||
### `tls`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `profile-id` | — | Required in production; the only TLS identifier the actuator exposes |
|
||||
| `protocols` | `TLSv1.3, TLSv1.2` | Anything else is rejected |
|
||||
| `hostname-verification` | `true` | Setting it false fails startup |
|
||||
| `trust-all` | `false` | Exists only so the unsafe intent is rejectable; nothing acts on `true` |
|
||||
| `allow-plain-http` | `false` | Plaintext fallback fails startup in production |
|
||||
| `trust-material-reference` | — | Custom CA, resolved by the secret loader |
|
||||
| `key-material-reference` | — | Client certificate for mTLS |
|
||||
|
||||
### `proxy` and `observability`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `proxy.enabled` | `false` | |
|
||||
| `proxy.host` / `proxy.port` / `proxy.type` | — / `0` / `HTTP` | |
|
||||
| `proxy.credential-provider` | — | Proxy authentication is separate from target authentication |
|
||||
| `proxy.connect-timeout` | `500ms` | Recorded as its own metric |
|
||||
| `proxy.import-ambient-no-proxy` | `false` | Ambient `NO_PROXY` never widens a validated profile |
|
||||
| `observability.operation-name-required` | `true` | |
|
||||
| `observability.full-url-recording` | `false` | |
|
||||
| `observability.body-logging` | `false` | |
|
||||
|
||||
## `app.httpclient.dynamic-targets[N]`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | — | Required, unique, and subject to the same normalisation rule as a client name |
|
||||
| `allowed-schemes` | `https` | |
|
||||
| `allowed-ports` | `443` | |
|
||||
| `allowed-host-suffixes` | empty | |
|
||||
| `allowed-hosts` | empty | Empty means "any host that survives address validation" |
|
||||
| `max-redirect-hops` | `0` | Each hop repeats the full validation flow |
|
||||
| `trace-propagation` | `false` | Off by default for dynamic targets |
|
||||
| `blocked-cidrs` | empty | Organisation-defined internal ranges |
|
||||
|
||||
## Startup violation codes
|
||||
|
||||
`TRUSTED_BASE_URL_REQUIRED`, `BASE_URL_USERINFO_FORBIDDEN`, `BASE_URL_QUERY_FORBIDDEN`,
|
||||
`PLAINTEXT_PRODUCTION_TARGET`, `ALLOWED_HOST_MISMATCH`, `ALLOWED_PORT_MISMATCH`,
|
||||
`REDIRECT_POLICY_INVALID`, `REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED`,
|
||||
`INVALID_TIMEOUT_BUDGET`, `RESPONSE_HARD_MAXIMUM_EXCEEDED`, `PRODUCTION_SIMPLE_FACTORY_FORBIDDEN`,
|
||||
`JDK_FINE_GRAINED_POOL_UNSUPPORTED`, `HTTP3_STABLE_FORBIDDEN`,
|
||||
`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`, `DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN`,
|
||||
`OAUTH2_REGISTRATION_REQUIRED`, `API_KEY_HEADER_NAME_REQUIRED`, `TRUST_ALL_FORBIDDEN`,
|
||||
`HOSTNAME_VERIFICATION_REQUIRED`, `PLAINTEXT_FALLBACK_FORBIDDEN`, `TLS_PROTOCOL_FORBIDDEN`,
|
||||
`RETRY_BACKOFF_REQUIRED`, `MISSING_PRODUCTION_SETTING`, `DUPLICATE_CLIENT_NAME`,
|
||||
`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, `DYNAMIC_BASE_URL_REQUIRED`,
|
||||
`DYNAMIC_TARGET_PROXY_UNSUPPORTED`, `REACTIVE_AUTHENTICATION_UNSUPPORTED`,
|
||||
`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`, `POOL_ROUTE_EXCEEDS_TOTAL`,
|
||||
`TLS_PROTOCOL_SET_REQUIRED`, `REACTIVE_REDIRECT_UNSUPPORTED`,
|
||||
`RETRY_POLICY_CONTRADICTS_ATTEMPTS`, `FULL_URL_RECORDING_FORBIDDEN`, `BODY_LOGGING_FORBIDDEN`,
|
||||
`DNS_TIMEOUT_UNSUPPORTED`, `PROXY_CREDENTIAL_UNSUPPORTED`, `PROXY_AMBIENT_NO_PROXY_UNSUPPORTED`.
|
||||
|
||||
The last three name settings the platform binds but cannot yet honour. Neither the Apache classic
|
||||
client nor the JDK client exposes a DNS-resolution timeout, and no proxy-credential path exists, so
|
||||
a non-default value is refused rather than accepted and ignored. Leaving the defaults alone is
|
||||
unaffected — only a deliberate, unmet request fails.
|
||||
|
||||
Three of these are about a guarantee that used to be silently unmet rather than refused:
|
||||
|
||||
- `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` — declaring `protocols: [HTTP_2]` alone states that HTTP/2
|
||||
is required. Only `REACTOR_NETTY` can be configured to offer H2 and nothing else; the JDK client
|
||||
treats it as a preference and negotiates HTTP/1.1, and Apache's classic client is HTTP/1.1 only.
|
||||
- `POOL_ROUTE_EXCEEDS_TOTAL` — a per-route ceiling above the total is incoherent, and on Reactor,
|
||||
where the per-route knob is the only one that exists, it silently becomes the effective limit.
|
||||
- `TLS_PROTOCOL_SET_REQUIRED` — an empty `tls.protocols` used to pass and then let the JVM choose,
|
||||
so emptying the list to "tighten" a profile loosened it.
|
||||
- `REACTIVE_REDIRECT_UNSUPPORTED` — engine redirect following is disabled on every transport and
|
||||
only the blocking stack has a coordinator that follows hops with per-hop re-validation. A
|
||||
`WEB_CLIENT` profile with `redirect.enabled=true` did not follow redirects; the caller received the
|
||||
3xx as an ordinary response. Refused until the reactive coordinator exists.
|
||||
- `RETRY_POLICY_CONTRADICTS_ATTEMPTS` — `retry.policy` was read by nothing on the execution path, so
|
||||
the actuator could report `none` for a profile retrying three times. The two settings must now
|
||||
agree: `policy: none` requires `max-attempts: 1`, and any other policy requires more than one.
|
||||
- `FULL_URL_RECORDING_FORBIDDEN` / `BODY_LOGGING_FORBIDDEN` — both settings were bindable and inert.
|
||||
Recording an expanded URL puts path identifiers and query strings into unbounded metric tags;
|
||||
recording bodies puts someone else's data into logs. Representable so the intent is rejectable,
|
||||
refused under a production profile.
|
||||
|
||||
`DYNAMIC_TARGET_PROXY_UNSUPPORTED` is worth spelling out: a forward proxy resolves the hostname on
|
||||
its own side, so the addresses this platform validated and pinned are not the addresses the
|
||||
connection reaches. The SSRF defence would be present, correct, and bypassed — so the combination is
|
||||
refused rather than served with a guarantee it cannot keep.
|
||||
@@ -0,0 +1,179 @@
|
||||
# HTTP Client platform — Java field path to environment variable template.
|
||||
#
|
||||
# The SSOT is HttpClientPlatformSettings. HttpClientEnvironmentKeys derives this list from the
|
||||
# record tree at runtime, HttpClientPlatformEnvManifestTest fails when the two disagree in either
|
||||
# direction, and the platform refuses to start on an APP_HTTPCLIENT_ variable that is not here. So a
|
||||
# field added with no entry, an entry whose field was renamed, and a misspelled variable in a
|
||||
# deployment are all failures rather than silence.
|
||||
#
|
||||
# `N` and `M` are list indices, not literals: `N` for the outermost list, `M` for a list inside it.
|
||||
# `app.httpclient.clients[N].base-url` is set as APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first
|
||||
# client, and `clients[N].allowed-hosts[M]` as APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0.
|
||||
#
|
||||
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
|
||||
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
|
||||
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||
# in the environment — templating an indexed client in application.yml would materialise a nameless
|
||||
# client in every deployment, which the settings' aggregate validation refuses.
|
||||
#
|
||||
# This file lives beside the HTTP Client documentation rather than in docs/registries, which is a
|
||||
# fail-closed catalog of exactly eight contract registries with a fixed row schema
|
||||
# (owner_branch/compatibility_impact/required_test per row). A field-to-variable mapping does not
|
||||
# have that shape, and admitting it would have meant loosening a gate rather than satisfying one.
|
||||
#
|
||||
# Secrets are referenced, never carried: authentication.secret-reference, tls.*-material-reference
|
||||
# and proxy.credential-provider name material that a secret backend resolves. Putting the material
|
||||
# itself in one of these variables defeats the indirection they exist for.
|
||||
fields:
|
||||
- field: enabled
|
||||
env: APP_HTTPCLIENT_ENABLED
|
||||
- field: clients[N].name
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_NAME
|
||||
- field: clients[N].mode
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_MODE
|
||||
- field: clients[N].base-url
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL
|
||||
- field: clients[N].allowed-hosts[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_HOSTS_M
|
||||
- field: clients[N].allowed-ports[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_PORTS_M
|
||||
- field: clients[N].api
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_API
|
||||
- field: clients[N].transport
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TRANSPORT
|
||||
- field: clients[N].protocols[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROTOCOLS_M
|
||||
- field: clients[N].pool.max-total-connections
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_TOTAL_CONNECTIONS
|
||||
- field: clients[N].pool.max-connections-per-route
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_CONNECTIONS_PER_ROUTE
|
||||
- field: clients[N].pool.max-pending-acquires
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_PENDING_ACQUIRES
|
||||
- field: clients[N].pool.pending-acquire-timeout
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_PENDING_ACQUIRE_TIMEOUT
|
||||
- field: clients[N].pool.max-idle-time
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_IDLE_TIME
|
||||
- field: clients[N].pool.max-life-time
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_LIFE_TIME
|
||||
- field: clients[N].pool.validate-after-inactivity
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_VALIDATE_AFTER_INACTIVITY
|
||||
- field: clients[N].pool.eviction-interval
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_EVICTION_INTERVAL
|
||||
- field: clients[N].pool.shutdown-timeout
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_SHUTDOWN_TIMEOUT
|
||||
- field: clients[N].pool.requires-route-pool
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_ROUTE_POOL
|
||||
- field: clients[N].pool.requires-bounded-pending-queue
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_BOUNDED_PENDING_QUEUE
|
||||
- field: clients[N].timeout.dns
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_DNS
|
||||
- field: clients[N].timeout.connect
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_CONNECT
|
||||
- field: clients[N].timeout.tls-handshake
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TLS_HANDSHAKE
|
||||
- field: clients[N].timeout.proxy-connect
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_PROXY_CONNECT
|
||||
- field: clients[N].timeout.request-write-idle
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_REQUEST_WRITE_IDLE
|
||||
- field: clients[N].timeout.response-header
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_RESPONSE_HEADER
|
||||
- field: clients[N].timeout.read-idle
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_READ_IDLE
|
||||
- field: clients[N].timeout.total-call
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TOTAL_CALL
|
||||
- field: clients[N].timeout.streaming-idle
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_STREAMING_IDLE
|
||||
- field: clients[N].redirect.enabled
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ENABLED
|
||||
- field: clients[N].redirect.max-hops
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_MAX_HOPS
|
||||
- field: clients[N].redirect.allow-cross-origin
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ALLOW_CROSS_ORIGIN
|
||||
- field: clients[N].request.max-body-bytes
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_MAX_BODY_BYTES
|
||||
- field: clients[N].request.compression
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_COMPRESSION
|
||||
- field: clients[N].response.max-wire-bytes
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_WIRE_BYTES
|
||||
- field: clients[N].response.max-decoded-bytes
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_DECODED_BYTES
|
||||
- field: clients[N].response.allowed-content-types[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_ALLOWED_CONTENT_TYPES_M
|
||||
- field: clients[N].authentication.type
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_TYPE
|
||||
- field: clients[N].authentication.registration-id
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_REGISTRATION_ID
|
||||
- field: clients[N].authentication.scopes[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SCOPES_M
|
||||
- field: clients[N].authentication.audience
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_AUDIENCE
|
||||
- field: clients[N].authentication.header-name
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_HEADER_NAME
|
||||
- field: clients[N].authentication.secret-reference
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SECRET_REFERENCE
|
||||
- field: clients[N].retry.policy
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_POLICY
|
||||
- field: clients[N].retry.max-attempts
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_ATTEMPTS
|
||||
- field: clients[N].retry.base-backoff
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BASE_BACKOFF
|
||||
- field: clients[N].retry.max-backoff
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_BACKOFF
|
||||
- field: clients[N].retry.jitter
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_JITTER
|
||||
- field: clients[N].retry.retry-after
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_RETRY_AFTER
|
||||
- field: clients[N].retry.budget
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BUDGET
|
||||
- field: clients[N].observability.operation-name-required
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_OPERATION_NAME_REQUIRED
|
||||
- field: clients[N].observability.full-url-recording
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_FULL_URL_RECORDING
|
||||
- field: clients[N].observability.body-logging
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_BODY_LOGGING
|
||||
- field: clients[N].tls.profile-id
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROFILE_ID
|
||||
- field: clients[N].tls.protocols[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROTOCOLS_M
|
||||
- field: clients[N].tls.hostname-verification
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_HOSTNAME_VERIFICATION
|
||||
- field: clients[N].tls.trust-all
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_ALL
|
||||
- field: clients[N].tls.allow-plain-http
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_ALLOW_PLAIN_HTTP
|
||||
- field: clients[N].tls.trust-material-reference
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_MATERIAL_REFERENCE
|
||||
- field: clients[N].tls.key-material-reference
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_KEY_MATERIAL_REFERENCE
|
||||
- field: clients[N].proxy.enabled
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_ENABLED
|
||||
- field: clients[N].proxy.host
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_HOST
|
||||
- field: clients[N].proxy.port
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_PORT
|
||||
- field: clients[N].proxy.type
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_TYPE
|
||||
- field: clients[N].proxy.credential-provider
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CREDENTIAL_PROVIDER
|
||||
- field: clients[N].proxy.connect-timeout
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CONNECT_TIMEOUT
|
||||
- field: clients[N].proxy.import-ambient-no-proxy
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_IMPORT_AMBIENT_NO_PROXY
|
||||
- field: clients[N].experimental-acknowledgement
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_EXPERIMENTAL_ACKNOWLEDGEMENT
|
||||
- field: dynamic-targets[N].name
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_NAME
|
||||
- field: dynamic-targets[N].allowed-schemes[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_SCHEMES_M
|
||||
- field: dynamic-targets[N].allowed-ports[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_PORTS_M
|
||||
- field: dynamic-targets[N].allowed-host-suffixes[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOST_SUFFIXES_M
|
||||
- field: dynamic-targets[N].allowed-hosts[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOSTS_M
|
||||
- field: dynamic-targets[N].max-redirect-hops
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_MAX_REDIRECT_HOPS
|
||||
- field: dynamic-targets[N].trace-propagation
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_TRACE_PROPAGATION
|
||||
- field: dynamic-targets[N].blocked-cidrs[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_BLOCKED_CIDRS_M
|
||||
@@ -0,0 +1,64 @@
|
||||
# Migrating from `RestTemplate`
|
||||
|
||||
`RestTemplate` is permitted only inside `…httpclient.migration`; `RestTemplateBoundaryTest` enforces
|
||||
that. New retry, Dynamic Target, and HTTP/3 capabilities are deliberately unreachable from the
|
||||
migration path — a caller that wants them moves to a Named Client Profile.
|
||||
|
||||
## 1. Audit before changing anything
|
||||
|
||||
```java
|
||||
RestTemplateInventory inventory = new RestTemplateInventoryScanner().scan(existingTemplate);
|
||||
```
|
||||
|
||||
The inventory reports the request factory, message converters, interceptors, error handler, and URI
|
||||
template handler, plus findings:
|
||||
|
||||
| Code | Severity | Meaning |
|
||||
|---|---|---|
|
||||
| `SIMPLE_REQUEST_FACTORY` | blocking | no connection pool; unsupported in production |
|
||||
| `NO_MESSAGE_CONVERTERS` | blocking | the template cannot encode or decode a body |
|
||||
| `NO_INTERCEPTORS` | warning | confirm where correlation and timeouts are applied |
|
||||
| `TIMEOUTS_NOT_INTROSPECTABLE` | informational | declare timeouts explicitly on the target profile |
|
||||
|
||||
## 2. Bridge without changing behaviour
|
||||
|
||||
```java
|
||||
RestClient client = new RestTemplateToRestClientAdapter().adaptChecked(existingTemplate);
|
||||
```
|
||||
|
||||
`adaptChecked` refuses to migrate a template with a blocking finding. The bridge carries the
|
||||
existing converters, interceptors, error handler, and URI handler across, so this step changes the
|
||||
API and nothing else.
|
||||
|
||||
## 3. Move to a Named Client Profile
|
||||
|
||||
Turn the platform on with `APP_HTTPCLIENT_ENABLED=true` — it ships off, and while it is off none of
|
||||
the settings below are bound — then declare the upstream as `app.httpclient.clients[N]` with its
|
||||
`name` and an explicit base URL, transport, timeouts, pool, body limits, authentication, retry
|
||||
policy, redirect policy, and TLS profile. Startup validation will tell you exactly which of those is
|
||||
missing. See `docs/httpclient/configuration-reference.md` for the environment form.
|
||||
|
||||
## 4. Move to a typed client
|
||||
|
||||
```java
|
||||
@HttpClientProfile("payment")
|
||||
@HttpExchange("/payments")
|
||||
public interface PaymentClient {
|
||||
|
||||
@PostExchange
|
||||
@HttpOperationPolicy(
|
||||
name = "create-payment",
|
||||
idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED,
|
||||
retryPolicy = "payment-write")
|
||||
PaymentResponse create(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request);
|
||||
}
|
||||
```
|
||||
|
||||
The interface fails startup validation unless it declares a profile, gives every method a stable
|
||||
operation name and an explicit idempotency, supplies a key parameter when the operation requires
|
||||
one, keeps a single execution model, and does not enable retry on a non-idempotent write.
|
||||
|
||||
## 5. Retire the template
|
||||
|
||||
Once no production package references `RestTemplate`, `RestTemplateBoundaryTest` keeps it that way.
|
||||
@@ -0,0 +1,87 @@
|
||||
# HTTP Client Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||
|
||||
The design package states its own adaptation rule:
|
||||
|
||||
> 실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적
|
||||
> 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과
|
||||
> 정책 의미론은 유지한다.
|
||||
|
||||
This file is the single record of *how* the design's assumed layout was mapped onto this repository.
|
||||
Only paths, build DSL, and composition-root ownership changed. Public contracts, policy order, and
|
||||
error semantics are implemented exactly as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The design assumes a greenfield library with 19 Gradle projects under `modules/httpclient/`.
|
||||
This repository is a Clean Architecture template whose **fail-closed registry**
|
||||
(`src/config/architecture/modules.json`, enforced by `src/settings.gradle` and
|
||||
`verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 19 more
|
||||
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the design's 19 library modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:httpclient`, with two exceptions driven by this repository's own rules:
|
||||
|
||||
| Design module | Repository home | Reason |
|
||||
|---|---|---|
|
||||
| `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. |
|
||||
| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/test/java/**/testkit` | The design forbids production modules depending on the testkit; a test source set gives the same guarantee without a new Gradle project. |
|
||||
|
||||
The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`,
|
||||
`HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbound.httpclient`.
|
||||
|
||||
| Design module | Design package | Repository package |
|
||||
|---|---|---|
|
||||
| `httpclient-core-api` | `…httpclient.api` (+ `.body`, `.error`, `.operation`, `.result`) | `dev.caskeleton.adapter.outbound.httpclient.api` (+ same subpackages) |
|
||||
| `httpclient-profile` | `…httpclient.profile` | `…outbound.httpclient.profile` |
|
||||
| `httpclient-transport-spi` | `…httpclient.transport` | `…outbound.httpclient.transport` |
|
||||
| `httpclient-transport-apache` | `…httpclient.apache` | `…outbound.httpclient.apache` |
|
||||
| `httpclient-transport-jdk` | `…httpclient.jdk` | `…outbound.httpclient.jdk` |
|
||||
| `httpclient-restclient` | `…httpclient.restclient` | `…outbound.httpclient.restclient` |
|
||||
| `httpclient-resilience` | `…httpclient.resilience` | `…outbound.httpclient.resilience` |
|
||||
| `httpclient-auth` | `…httpclient.auth` | `…outbound.httpclient.auth` |
|
||||
| `httpclient-security` | `…httpclient.security` | `…outbound.httpclient.security` |
|
||||
| `httpclient-observability` | `…httpclient.observation` | `…outbound.httpclient.observation` |
|
||||
| `httpclient-transport-reactor-netty` | `…httpclient.reactor` | `…outbound.httpclient.reactor` |
|
||||
| `httpclient-webclient` | `…httpclient.webclient` | `…outbound.httpclient.webclient` |
|
||||
| `httpclient-service-client` | `…httpclient.service` | `…outbound.httpclient.service` |
|
||||
| `httpclient-dynamic-target` | `…httpclient.dynamic` | `…outbound.httpclient.dynamic` |
|
||||
| `httpclient-resttemplate-migration` | `…httpclient.migration` | `…outbound.httpclient.migration` |
|
||||
| `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` |
|
||||
| `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` |
|
||||
| `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` |
|
||||
| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (test source set) |
|
||||
|
||||
## 3. Other deliberate substitutions
|
||||
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
|
||||
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
|
||||
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
|
||||
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| `docs/httpclient/**`, `.github/workflows/httpclient-*.yml`, `scripts/verify-httpclient-docs.py` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
|
||||
## 4. What is unchanged from the design
|
||||
|
||||
- H1 / H2 / H3 / H4 exposure rules and the forbidden native-engine signatures.
|
||||
- `ExecutionEvidence`, `BodyReplayability`, `OperationIdempotency`, `AttemptStage`, `FailureCategory`.
|
||||
- `HttpOperation`, `HttpCallResult`, `BodySource`, `ResponseType`, `BlockingStreamingResponse`.
|
||||
- The complete stable exception hierarchy and `HttpFailureMetadata` redaction rules.
|
||||
- Named Client Profile schema, startup validation codes, and operation override direction.
|
||||
- Effective deadline formula, attempt budget, and streaming setup/idle split.
|
||||
- Retry eligibility inputs, the ordered decision table, retry budget, and backoff rules.
|
||||
- Circuit → Rate Limiter → Bulkhead attempt order and logical admission placement.
|
||||
- OAuth2 cache key, single-flight refresh, and the 401 replay-at-most-once rule.
|
||||
- TLS allow/forbid lists and permanent-failure classification.
|
||||
- Dynamic Target canonicalization → all-answer DNS validation → pinning → redirect revalidation.
|
||||
- Low-cardinality tag allowlist, forbidden labels, trace and logging rules.
|
||||
- Runtime generation swap and drain semantics.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
# 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-core` must stay free of framework/transport/database/cloud dependencies.
|
||||
- `application-core` must 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_ENABLED`
|
||||
must not be a second master switch.
|
||||
- Every new `APP_*` key must land in all four places or `verifyEnvKeys` fails:
|
||||
`src/app-bootstrap/src/main/resources/application.yml`, `src/.env`,
|
||||
`docs/registries/env-keys.yaml`, and (when secret-classified)
|
||||
`docs/registries/secrets-classification.yaml`.
|
||||
- `SecretsClassificationRegistryTest` asserts `SecretSourceValidator.REQUIRED_PROD_SECRETS` matches
|
||||
`docs/registries/secrets-classification.yaml` 1:1. Changing one requires changing the other.
|
||||
- Netty floor: `4.2.16` or 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):
|
||||
|
||||
- `CaSkeletonApplication` scans `dev.caskeleton.adapter` for `@ConfigurationProperties`, so
|
||||
`RedisSdkSettings` (`ca-skeleton.capabilities.redis-sdk`) is registered with Redis off.
|
||||
- `RedisSdkSettings.validate()` has no production caller.
|
||||
- The `cache-redis` leaf has **no** `@Bean`, `@Configuration`, or `@AutoConfiguration` in main
|
||||
source: nothing constructs a client, connection, gateway, or health contributor.
|
||||
- 240 tracked main-source files under `cache-redis` are 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.*`, and
|
||||
`ca-skeleton.security.redis-session.*` in `application.yml` bind to **no** Java type — orphan
|
||||
configuration from the previous generation.
|
||||
- `SecretSourceValidator.REQUIRED_PROD_SECRETS` requires `APP_CACHE_REDIS_PASSWORD` and
|
||||
`APP_CACHE_REDIS_KEY_HMAC_SECRET` unconditionally in prod; the other Redis roles have
|
||||
conditional skips.
|
||||
- `verifyEnvKeys` compares only the three text sets (`.env`, `application.yml` placeholders,
|
||||
`env-keys.yaml`); it never reads `spring-configuration-metadata.json`, so a typed property with
|
||||
no env name passes.
|
||||
- `redisTopologyTest` builds its tag as `lane-${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.lockfile` pins `io.netty:*:4.2.7.Final` on
|
||||
`productionRuntimeClasspath`, and still carries a `redisCompositionTestRuntimeClasspath`
|
||||
configuration 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_SECRETS` without any `APP_CACHE_REDIS_*` entry;
|
||||
`isCacheRedisMaterial(String)` + `isRedisGloballyEnabled()` private helpers gating every
|
||||
remaining Redis-prefixed secret on `app.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.enabled` plus its role selector.**
|
||||
- [ ] **Step 4: Re-run the focused test class.**
|
||||
- [ ] **Step 5: Update `secrets-classification.yaml` `required_in_prod` metadata 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** move `RedisSdkSettings` out of a scanned package —
|
||||
preferred: keep the class where it is and drop `@ConfigurationProperties` from it, binding it
|
||||
instead from the conditional configuration with `@ConfigurationProperties` on the `@Bean` method.
|
||||
- Test: new bootstrap contract test asserting zero `RedisSdkSettings` beans when
|
||||
`app.redis.enabled` is 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` (add `app.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:
|
||||
|
||||
```lua
|
||||
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-core` or
|
||||
`shared-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.** `IdempotencyExecutorV2` targets a contract no provider implements.
|
||||
- **Gateway / `CommandRequest` visibility.** Narrowing it is a package restructuring across
|
||||
`sdk.programmability`, `sdk.raw`, `sdk.admin` and `sdk.extensions`, not an access-modifier change.
|
||||
Reference in New Issue
Block a user