Compare commits

...
3 Commits
Author SHA1 Message Date
DongHyeonka 0a6dd0e419 refactor: adapter 구현중.. 2026-08-13 02:21:34 +09:00
DongHyeonkaandClaude Opus 5 0cd959a494 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>
2026-08-11 16:49:31 +09:00
DongHyeonkaandClaude Opus 5 5f10b791d3 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>
2026-08-11 16:48:43 +09:00
2032 changed files with 160553 additions and 74889 deletions
+99 -4
View File
@@ -24,6 +24,13 @@ gates:
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: conditional-transport-qualification
release_blocking: true
mechanism: gradle-custom-task
ref: conditionalTransportQualification
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: clean-architecture-dependencies
release_blocking: true
mechanism: gradle-custom-task
@@ -101,12 +108,12 @@ gates:
workflow: ci-quality-gates.yml
job: gate-matrix-lint
execution: job
- id: redis-standalone
- id: redis-sdk
release_blocking: true
mechanism: workflow-job
ref: redis-standalone
ref: redis-sdk
workflow: ci-quality-gates.yml
job: redis-standalone
job: redis-sdk
execution: job
- id: jpa-candidate-evidence
release_blocking: true
@@ -171,7 +178,7 @@ gates:
workflow: object-storage-qualification.yml
job: minio-managed-contract
execution: explicit
- id: poster-image-v7-migration
- id: poster-image-migration
release_blocking: true
mechanism: gradle-custom-task
ref: posterImageMigrationTest
@@ -192,3 +199,91 @@ gates:
workflow: object-storage-qualification.yml
job: aws-managed-common-subset
execution: job
- id: redis-sdk-support-matrix
release_blocking: true
mechanism: contract-test
ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
# Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that
# touches the Redis leaf runs the standalone lane, and the full supported-version x topology
# matrix runs nightly and on a release candidate. While it was dispatch-only, a release could
# claim topology evidence that nobody had produced for that commit.
- id: redis-sdk-topology-evidence
release_blocking: conditional
mechanism: workflow-job
ref: topology-evidence
workflow: redis-sdk-topology.yml
job: topology-evidence
execution: job
- id: httpclient-stable-contract
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientStableContractTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-security-suite
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientSecurityTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-fault-injection
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientFailureInjectionTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-performance-certification
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientPerformanceTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-spring62-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: spring62ApiSurfaceScan
workflow: httpclient-release.yml
job: release-gate
execution: explicit
# The 6.2 API-surface scan above proves the common packages compile against the older surface. It
# does not prove they run on it, and the two were being conflated: a lane called
# "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to
# say what it does, and the runtime claim is registered here as its own delegated-pending control
# so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring
# Framework 6.2 distribution resolved into a separate test runtime, which this repository's
# Boot 4.0 baseline does not carry.
- id: httpclient-spring62-runtime
release_blocking: conditional
mechanism: delegated-pending
ref: spring62-runtime-lane
workflow: httpclient-release.yml
job: release-gate
execution: job
- id: httpclient-spring70-compatibility
release_blocking: true
mechanism: gradle-custom-task
ref: spring70CompatibilityTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-documentation-drift
release_blocking: true
mechanism: workflow-job
ref: httpclient-documentation
workflow: httpclient-release.yml
job: httpclient-documentation
execution: job
- id: httpclient-event-loop-blocking
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientBlockHoundTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
+179 -16
View File
@@ -2,15 +2,34 @@
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
readonly EXPECTED_GATE_COUNT=26
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
exit 1
if (( $# > 1 )); then
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
exit 2
fi
if (( $# == 1 )); then
if [[ ! -d "$1" ]]; then
printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2
exit 2
fi
REPO_ROOT="$(cd -- "$1" && pwd -P)"
else
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2
exit 1
fi
fi
readonly REPO_ROOT
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it.
readonly EXPECTED_GATE_COUNT=38
if [[ ! -f "${MATRIX}" ]]; then
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
exit 1
@@ -80,6 +99,146 @@ job_body() {
' "${workflow_file}"
}
gradle_command_has_safe_literal_grammar() {
local command="$1"
[[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]]
}
gradle_token_suppresses_execution() {
local token="$1"
case "${token}" in
'--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;;
*) return 1 ;;
esac
}
gradle_token_is_allowed_gate_argument() {
local token="$1"
case "${token}" in
'--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;;
esac
[[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]]
}
gradle_plugin_is_applied() {
local plugin_id="$1"
grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src"
}
gradle_custom_task_is_registered_in_build_file() {
local task_name="$1"
local build_file="$2"
if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then
return 0
fi
awk -v required_task="${task_name}" '
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
candidate=$0
sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate)
quote=substr(candidate, 1, 1)
if (quote != "\"" && quote != sprintf("%c", 39)) {
next
}
candidate=substr(candidate, 2)
closing_quote=index(candidate, quote)
if (closing_quote == 0) {
next
}
candidate=substr(candidate, 1, closing_quote - 1)
if (candidate == required_task) {
found=1
}
}
inside_registration && /\)[[:space:]]*$/ { inside_registration=0 }
END { exit found ? 0 : 1 }
' "${build_file}"
}
gradle_custom_task_is_registered() {
local task_name="$1"
local build_file
while IFS= read -r -d '' build_file; do
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
return 0
fi
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0)
return 1
}
gradle_token_matches_registered_task() {
local token="$1"
local required_task="$2"
local project_path build_file
if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then
return 0
fi
if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then
return 1
fi
project_path="${token%:"${required_task}"}"
project_path="${project_path#:}"
project_path="${project_path%:}"
build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle"
[[ -f "${build_file}" ]] \
&& gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}"
}
job_runs_gradle_task() {
local workflow_file="$1"
local job_id="$2"
local required_task="$3"
local command token
local found_task suppressed
local -a tokens=()
while IFS= read -r command; do
if ! gradle_command_has_safe_literal_grammar "${command}"; then
continue
fi
read -r -a tokens <<< "${command}"
if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then
continue
fi
found_task=0
suppressed=0
for token in "${tokens[@]:1}"; do
case "${token}" in
'&&'|'||'|';'|'|'|'#'*) break ;;
esac
if gradle_token_suppresses_execution "${token}"; then
suppressed=1
break
fi
if ! gradle_token_is_allowed_gate_argument "${token}"; then
suppressed=1
break
fi
if gradle_token_matches_registered_task "${token}" "${required_task}"; then
found_task=1
fi
done
if (( found_task == 1 && suppressed == 0 )); then
return 0
fi
done < <(
job_body "${workflow_file}" "${job_id}" | awk '
/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ {
command=$0
sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command)
if (command !~ /^(\||>)/) {
print command
}
}
'
)
return 1
}
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
[[ -z "${id}" ]] && continue
total=$((total + 1))
@@ -114,8 +273,11 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
case "${mechanism}" in
gradle-custom-task)
if ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \
--include='build.gradle'; then
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'")
continue
fi
if ! gradle_custom_task_is_registered "${ref}"; then
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
continue
fi
@@ -123,12 +285,13 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
gradle-plugin-task)
plugin="${ref%@*}"
task="${ref#*@}"
if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then
failures+=("gate '${id}' must use plugin@task for gradle-plugin-task")
if [[ "${plugin}" == "${ref}" \
|| ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \
|| ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'")
continue
fi
if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \
--include='build.gradle'; then
if ! gradle_plugin_is_applied "${plugin}"; then
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
continue
fi
@@ -158,7 +321,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
case "${execution}" in
check)
if ! job_body "${workflow_file}" "${job}" | grep -Eqs -- '\./gradlew[[:space:]]+check([[:space:]]|$)'; then
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
failures+=("gate '${id}' expects Gradle check in job '${job}'")
continue
fi
@@ -170,7 +333,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
fi
;;
explicit)
if ! job_body "${workflow_file}" "${job}" | grep -Fqs -- "${ref}"; then
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
continue
fi
+740
View File
@@ -0,0 +1,740 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
# 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 this entire sorted array in the same reviewed change. Never refresh a single digest
# merely to make this verifier pass.
readonly EXPECTED_WORKFLOW_LOCK=(
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
readonly EXPECTED_WRAPPER_PROPERTIES=(
'distributionBase=GRADLE_USER_HOME'
'distributionPath=wrapper/dists'
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
'networkTimeout=10000'
'validateDistributionUrl=true'
'zipStoreBase=GRADLE_USER_HOME'
'zipStorePath=wrapper/dists'
)
fail() {
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
fail 'expected exactly one repository-root argument'
fi
readonly REPOSITORY_ROOT=$1
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
fi
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
workflow_lock_valid=1
actual_workflow_lock=()
while IFS= read -r -d '' locked_workflow; do
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
locked_workflow_sha256='<invalid-file-type>'
else
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
fi
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
done < <(
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
| LC_ALL=C sort -z
)
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
workflow_lock_entry_count=${#actual_workflow_lock[@]}
fi
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
workflow_lock_valid=0
fi
done
workflow_count=0
gradle_job_count=0
while IFS= read -r -d '' workflow; do
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
for (known_field in step_fields) {
delete step_fields[known_field]
}
}
function reset_job() {
job = ""
in_steps = 0
steps_count = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function grammar_error(message) {
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
invalid = 1
}
function workflow_grammar_error(message) {
printf "%s: %s\n", workflow, message > "/dev/stderr"
invalid = 1
}
function validate_job_shape() {
if (job != "" && steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
}
function is_allowed_step_field(field) {
return field == "name" \
|| field == "id" \
|| field == "uses" \
|| field == "run" \
|| field == "if" \
|| field == "shell" \
|| field == "with" \
|| field == "env" \
|| field == "working-directory" \
|| field == "continue-on-error" \
|| field == "timeout-minutes"
}
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
value = trim(value)
if (value == "" || index(value, "\\") != 0) {
grammar_error("has unsupported uses scalar")
return
}
first = substr(value, 1, 1)
single_quote = sprintf("%c", 39)
if (first == "\"" || first == single_quote) {
quote = first
closing = 0
for (index_value = 2; index_value <= length(value); index_value++) {
if (substr(value, index_value, 1) == quote) {
closing = index_value
break
}
}
if (closing == 0) {
grammar_error("has unsupported uses scalar")
return
}
suffix = substr(value, closing + 1)
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
grammar_error("has unsupported uses scalar")
return
}
action = substr(value, 2, closing - 2)
if (index(action, quote) != 0) {
grammar_error("has unsupported uses scalar")
return
}
} else {
action = value
sub(/[[:space:]]+#.*$/, "", action)
action = trim(action)
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
grammar_error("has unsupported uses scalar")
return
}
}
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
grammar_error("has unsupported uses scalar")
}
}
function validate_run_scalar(value, first) {
value = trim(value)
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
return
}
first = substr(value, 1, 1)
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
grammar_error("has unsupported run scalar")
}
}
function validate_step_field(content, field, value, separator) {
content = trim(content)
if (content ~ /^[{[]/) {
grammar_error("contains unsupported flow-style step syntax")
return
}
if (content ~ /^<</) {
grammar_error("contains a forbidden step merge key")
return
}
if (content ~ /^[*&!]/) {
grammar_error("contains unsupported step anchor, alias, or tag syntax")
return
}
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
grammar_error("contains unsupported step field syntax")
return
}
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
if (!is_allowed_step_field(field)) {
grammar_error("contains unsupported step field: " field)
return
}
if (field in step_fields) {
grammar_error("contains duplicate step field: " field)
return
}
step_fields[field] = 1
if (field == "uses") {
validate_uses_scalar(value)
} else if (field == "run") {
validate_run_scalar(value)
}
}
BEGIN {
in_jobs = 0
invalid = 0
jobs_count = 0
single_quote = sprintf("%c", 39)
reset_job()
}
/^jobs:/ {
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
jobs_count++
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
in_jobs = 1
next
}
/^"jobs":/ {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
run_block == 0 && /^<<:/ {
workflow_grammar_error("workflow contains a forbidden merge key")
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job_shape()
reset_job()
in_jobs = 0
}
in_jobs && /^ [^[:space:]#]/ {
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
grammar_error("job declaration must use a canonical block mapping")
next
}
validate_job_shape()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/ || line_indent > 8) {
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
if (raw != " steps:") {
grammar_error("steps container must use a canonical block sequence")
next
}
steps_count++
if (steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
in_steps = 0
reset_step()
}
if (raw ~ /^ *<<:/) {
grammar_error("contains a forbidden merge key")
next
}
if (in_steps != 0 && raw ~ /^ - /) {
reset_step()
step_active = 1
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
grammar_error("contains unsupported empty step syntax")
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
grammar_error("contains unsupported step-list syntax")
}
}
END {
validate_job_shape()
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
if (invalid) {
exit 1
}
}
' "${workflow}"; then
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
if ! grep -Fq -- './gradlew' "${workflow}" \
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
continue
fi
((workflow_count += 1))
if ! jobs_in_workflow=$(
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
step_kind = ""
step_name = ""
step_id = ""
step_uses = ""
step_uses_action = ""
step_if = ""
step_if_present = 0
step_continue_on_error = 0
step_gradle = 0
step_gradle_line = 0
step_unsupported_gradle = 0
step_field_count = 0
step_name_line = 0
step_id_line = 0
step_uses_line = 0
step_extra_field = ""
for (known_field in step_fields) {
delete step_fields[known_field]
delete step_field_raw[known_field]
}
}
function reset_job() {
job = ""
checkout_line = 0
validation_line = 0
gradle_line = 0
in_steps = 0
unsupported_gradle = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function has_gradle_reference(line) {
return index(line, "./gradlew") != 0 \
|| index(line, "gradle/actions/dependency-submission@") != 0
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function normalize_action(value, scalar, first, quote, closing, index_value) {
scalar = trim(value)
first = substr(scalar, 1, 1)
if (first == "\"" || first == single_quote) {
quote = first
closing = index(substr(scalar, 2), quote)
if (closing == 0) {
return ""
}
return substr(scalar, 2, closing - 1)
}
sub(/[[:space:]]+#.*$/, "", scalar)
return trim(scalar)
}
function record_gradle(line_number) {
step_gradle = 1
if (step_gradle_line == 0) {
step_gradle_line = line_number
}
if (gradle_line == 0) {
gradle_line = line_number
}
}
function record_uses(value, line_number, action) {
if (step_kind == "run") {
if (index(value, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "uses"
action = normalize_action(value)
step_uses = trim(value)
step_uses_action = action
step_uses_line = line_number
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
checkout_line = line_number
}
if (action == dependency_action) {
record_gradle(line_number)
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
record_gradle(line_number)
step_unsupported_gradle = 1
}
}
function record_run(value, line_number) {
if (step_kind == "uses") {
if (index(value, "./gradlew") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "run"
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
} else if (index(value, "./gradlew") != 0) {
record_gradle(line_number)
}
}
function record_step_field(content, line_number, separator, field, value) {
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
step_fields[field] = 1
step_field_raw[field] = trim(content)
step_field_count++
if (field == "name") {
step_name = trim(value)
step_name_line = line_number
} else if (field == "id") {
step_id = trim(value)
step_id_line = line_number
} else if (field == "uses") {
record_uses(value, line_number)
} else if (field == "run") {
record_run(trim(value), line_number)
} else if (field == "if") {
step_if_present = 1
step_if = trim(value)
} else if (field == "continue-on-error") {
step_continue_on_error = 1
}
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
step_extra_field = step_field_raw[field]
}
}
function validate_wrapper_step() {
if (step_uses_action != validation_reference) {
return
}
if (step_extra_field != "") {
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
invalid = 1
return
}
if (step_field_count != 3 \
|| step_name != "Validate Gradle wrapper" \
|| step_id != "gradle-wrapper-validation" \
|| step_uses != validation_action \
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
invalid = 1
return
}
if (validation_line == 0) {
validation_line = step_uses_line
}
}
function validate_gradle_step() {
if (step_gradle == 0 && step_unsupported_gradle == 0) {
return
}
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
unsupported_gradle = 1
}
if (step_if_present != 0 && step_if != guarded_gradle_if) {
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
invalid = 1
}
if (step_continue_on_error != 0) {
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
invalid = 1
}
}
function finalize_step() {
if (step_active == 0) {
return
}
validate_wrapper_step()
validate_gradle_step()
}
function start_step() {
finalize_step()
reset_step()
step_active = 1
}
function validate_job() {
finalize_step()
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
return
}
gradle_jobs++
if (unsupported_gradle != 0) {
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
invalid = 1
}
if (gradle_line == 0) {
return
} else if (checkout_line == 0) {
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (validation_line == 0) {
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
invalid = 1
}
}
BEGIN {
in_jobs = 0
invalid = 0
gradle_jobs = 0
single_quote = sprintf("%c", 39)
validation_reference = validation_action
sub(/[[:space:]]+#.*$/, "", validation_reference)
reset_job()
}
/^jobs:[[:space:]]*(#.*)?$/ {
in_jobs = 1
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job()
reset_job()
in_jobs = 0
}
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
validate_job()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/) {
next
}
if (line_indent > 8) {
if (index(raw, "./gradlew") != 0) {
record_gradle(NR)
}
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw == " steps:") {
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
finalize_step()
in_steps = 0
reset_step()
}
if (in_steps != 0 && raw ~ /^ - /) {
start_step()
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (has_gradle_reference(raw)) {
unsupported_gradle = 1
}
}
END {
validate_job()
print gradle_jobs
if (invalid) {
exit 1
}
}
' "${workflow}"
); then
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
((jobs_in_workflow > 0)) \
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
((gradle_job_count += jobs_in_workflow))
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
((workflow_lock_valid != 0)) \
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
printf 'gradle-wrapper-contract: PASS\n'
+26 -7
View File
@@ -21,6 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- name: Require the committed public-path security baseline
run: |
set -euo pipefail
@@ -44,12 +47,18 @@ jobs:
src/**/gradle.lockfile
- name: Check quality, public paths, and dependency locks
working-directory: src
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --stacktrace
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
- name: Qualify opt-in inbound transports without skips
working-directory: src
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
sample-off:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -70,10 +79,13 @@ jobs:
- name: Verify the gate matrix against the repository
run: bash .github/scripts/verify-gate-matrix.sh
redis-standalone:
redis-sdk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -83,14 +95,15 @@ jobs:
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify standalone Redis policy, provider, and composition contracts
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
# permit provenance, connection isolation, and the executor guard. There is no real-server
# lane yet — Tasks 10-17 add the contract suites that need one.
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
working-directory: src
run: >-
./gradlew
:application-core:redisPolicyContractTest
:shared-contract:edgeRateLimitContractTest
:adapter:outbound:cache-redis:check
:app-bootstrap:redisCompositionTest
verifyCleanArchitectureDependencies
verifyEnvKeys
verifyPublicPathSnapshot
@@ -102,6 +115,9 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -132,6 +148,9 @@ jobs:
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -150,7 +169,7 @@ jobs:
- quality-gates
- sample-off
- gate-matrix-lint
- redis-standalone
- redis-sdk
- jpa-candidate-evidence
if: always()
runs-on: ubuntu-latest
@@ -160,7 +179,7 @@ jobs:
QUALITY_RESULT: ${{ needs.quality-gates.result }}
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
REDIS_RESULT: ${{ needs.redis-standalone.result }}
REDIS_RESULT: ${{ needs.redis-sdk.result }}
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
run: |
set -euo pipefail
@@ -35,6 +35,9 @@ jobs:
contents: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
+132
View File
@@ -0,0 +1,132 @@
name: fileserver-nightly
# The environments that cannot run on every pull request: a real network filesystem, a foreign
# filesystem, and the long-running fault matrices. They are nightly rather than skipped because a
# green pull-request run is not certification of any of them.
on:
workflow_dispatch:
schedule:
- cron: '0 18 * * *'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
fileserver-nfs-ambiguity:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
FILESERVER_NFS_TESTS: "true"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Start the NFSv4 certification environment
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
- name: Run the network-filesystem ambiguity suite
working-directory: src
run: >-
./gradlew
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
--no-daemon
--stacktrace
- name: Tear down the NFS environment
if: always()
run: docker compose -f infra/fileserver/nfs/compose.yml down -v
fileserver-process-kill-matrix:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the crash matrix and reconciliation suites
working-directory: src
run: >-
./gradlew
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
:application-core:test --tests '*FileReconciliationServiceTest'
--rerun-tasks
--no-daemon
--stacktrace
fileserver-large-file-performance:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the large-file and slow-client suites under a constrained heap
working-directory: src
env:
GRADLE_OPTS: -Xmx512m
run: >-
./gradlew
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
--rerun-tasks
--no-daemon
--stacktrace
fileserver-multi-instance-lease:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Prove no run commits bytes from a stale lease
working-directory: src
run: >-
./gradlew
:application-core:test --tests '*MultiInstanceWriterLeaseTest'
--rerun-tasks
--no-daemon
--stacktrace
+164
View File
@@ -0,0 +1,164 @@
name: fileserver-pr
# Every claim in docs/fileserver/support-matrix.md that says "Stable" is backed by a job here.
# A support level with no job behind it is a marketing claim, not an engineering one, and
# DocumentationCoverageTest fails the build when the two drift apart.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/application-core/src/**/fileserver/**'
- 'src/adapter/inbound/web/src/**/fileserver/**'
- 'src/adapter/outbound/fileserver/**'
- 'src/adapter/outbound/persistence-jpa/src/**/fileserver/**'
- 'src/app-bootstrap/src/**/fileserver/**'
- 'docs/fileserver/**'
# The capability is not only its Java files. A change to the bound settings, the shipped
# environment, the registry that documents it, or the container that has to give it a
# writable volume changes how it behaves at runtime just as surely — and those were the
# exact files that could previously ship unverified.
- 'src/app-bootstrap/src/main/resources/application.yml'
- 'src/.env'
- 'docs/registries/env-keys.yaml'
- 'src/Dockerfile'
- 'docker-compose.yml'
- 'infra/nginx/**'
- 'infra/k8s/**'
- '.github/workflows/fileserver-pr.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
fileserver-unit-and-architecture:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the fileserver application and architecture suites
working-directory: src
run: >-
./gradlew
:application-core:test
:app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*Fileserver*'
--no-daemon
--stacktrace
fileserver-local-ext4-contract:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify the local content store against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:fileserver:test
--no-daemon
--stacktrace
fileserver-http-contract:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the servlet and reactive transport contracts
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
--no-daemon
--stacktrace
fileserver-security-suite:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the path, filename, range, and problem-detail hardening suite
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
--no-daemon
--stacktrace
fileserver-bounded-memory:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Prove transfer cost does not scale with file size
working-directory: src
run: >-
./gradlew
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
--no-daemon
--stacktrace
+143
View File
@@ -0,0 +1,143 @@
name: fileserver-release
# The gate a release must clear. Its job list is deliberately the same shape as the support matrix:
# nothing may be advertised at a support level whose evidence job is absent here.
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
fileserver-full-verification:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the architecture-wide dependency and module verification
working-directory: src
run: >-
./gradlew
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
- name: Run the complete fileserver suite across every leaf
working-directory: src
run: >-
./gradlew
:application-core:check
:adapter:inbound:web:check
:adapter:outbound:fileserver:check
--no-daemon
--stacktrace
fileserver-documentation-gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Prove every support claim maps to a job and every endpoint is documented
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
--no-daemon
--stacktrace
fileserver-pvc-certification:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
# when nothing had been applied anywhere.
- name: Check the certification manifest still says what the claim depends on
run: |
set -euo pipefail
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
test -f "$manifest"
grep -q 'kind: PersistentVolumeClaim' "$manifest"
grep -q 'kind: Job' "$manifest"
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
# mode would certify a topology the support matrix says is uncertified.
grep -q 'ReadWriteOnce' "$manifest"
! grep -q 'ReadWriteMany' "$manifest"
- name: Certify the ReadWriteOnce claim on the release cluster
id: pvc-cluster-run
env:
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
run: |
set -euo pipefail
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
echo "::warning::no release cluster configured; PVC certification was NOT run."
echo "The support matrix records this profile as Limited for exactly this reason:"
echo "the cluster result is produced by an operator against a real cluster and read"
echo "from docs/fileserver/storage-certification.md, not by this job."
echo "certified=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
kubectl logs job/fileserver-pvc-certification
echo "certified=true" >> "$GITHUB_OUTPUT"
fileserver-sensitive-telemetry-scan:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Prove telemetry carries no filename, path, or raw identifier
working-directory: src
run: >-
./gradlew
:application-core:test --tests '*FileserverObservabilityTest'
--no-daemon
--stacktrace
+132
View File
@@ -0,0 +1,132 @@
name: httpclient-contract
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
# disappearing into an aggregate run.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/outbound/httpclient/**'
- 'src/app-bootstrap/src/**/httpclient/**'
- 'docs/httpclient/**'
- 'scripts/verify-httpclient-docs.py'
- '.github/workflows/httpclient-contract.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
httpclient-unit-and-boundaries:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the focused module suite and the architecture gate
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
httpclient-stable-contract:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
transport: [apache, jdk, reactor]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify one transport against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientStableContractTest
-Phttpclient.contract.transports=${{ matrix.transport }}
--no-daemon
--stacktrace
httpclient-security-and-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the SSRF, cardinality, and Spring compatibility lanes
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientSecurityTest
:adapter:outbound:httpclient:httpClientBlockHoundTest
:adapter:outbound:httpclient:spring62ApiSurfaceScan
:adapter:outbound:httpclient:spring70CompatibilityTest
--no-daemon
--stacktrace
httpclient-composition:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify composition and architecture in the bootstrap module
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest'
--no-daemon
--stacktrace
+94
View File
@@ -0,0 +1,94 @@
name: httpclient-nightly
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
# fails, it does not skip.
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *'
permissions:
contents: read
jobs:
httpclient-fault-injection:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Inject TCP faults against a real upstream
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientFailureInjectionTest
--no-daemon
--stacktrace
httpclient-performance:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify pool, streaming, retry, and rotation bounds
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientPerformanceTest
--no-daemon
--stacktrace
httpclient-http3-experimental:
runs-on: ubuntu-latest
timeout-minutes: 30
# Experimental by design (D-08): the result is reported, never used to block a merge.
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Exercise the experimental HTTP/3 opt-in
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
-Phttp3.tests.enabled=true
--no-daemon
--stacktrace
+70
View File
@@ -0,0 +1,70 @@
name: httpclient-release
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
# single-line `./gradlew <task>` step, because .github/scripts/verify-gate-matrix.sh reads these
# commands to prove the gate is actually executed — a folded or flag-laden command would make the
# declaration in .github/ci-gate-matrix.yml unverifiable.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Focused module tests
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
- name: Spring 6.2 API surface lane
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
- name: Spring 7.0 compatibility lane
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
- name: Stable cross-transport contract suite
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
- name: SSRF and cardinality suite
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
- name: Event-loop blocking suite
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
- name: Toxiproxy fault-injection suite
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
- name: Resource-bound performance certification
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
- name: Architecture dependency gate
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
+3
View File
@@ -26,6 +26,9 @@ jobs:
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
+6
View File
@@ -5,6 +5,8 @@ on:
paths:
- "README.md"
- "src/README.md"
- "src/**/README.md"
- "src/**/CLAUDE.md"
- "docs/**/*.md"
- ".github/**/*.md"
- ".github/workflows/link-check.yml"
@@ -13,6 +15,8 @@ on:
paths:
- "README.md"
- "src/README.md"
- "src/**/README.md"
- "src/**/CLAUDE.md"
- "docs/**/*.md"
- ".github/**/*.md"
- ".github/workflows/link-check.yml"
@@ -38,6 +42,8 @@ jobs:
--root-dir .
README.md
src/README.md
'src/**/README.md'
'src/**/CLAUDE.md'
'docs/**/*.md'
'.github/**/*.md'
fail: true
@@ -23,6 +23,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -32,7 +35,7 @@ jobs:
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run non-skipping Poster image V7 migration qualification
- name: Run non-skipping Poster image migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
@@ -40,6 +43,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -58,6 +64,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -82,6 +91,9 @@ jobs:
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
@@ -1,375 +0,0 @@
name: redis-production-readiness
on:
schedule:
- cron: "23 18 * * *"
workflow_dispatch:
push:
tags:
- "v*-rc.*"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
resolve-redis-readiness:
runs-on: ubuntu-latest
outputs:
selected: ${{ steps.resolve.outputs.selected }}
candidates: ${{ steps.resolve.outputs.candidates }}
selected_count: ${{ steps.resolve.outputs.selected_count }}
sentinel_required: ${{ steps.resolve.outputs.sentinel_required }}
cluster_required: ${{ steps.resolve.outputs.cluster_required }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- name: Generate the strict checked-in readiness control artifact
working-directory: src
run: ./gradlew writeRedisCiMatrix --no-daemon --stacktrace
- id: resolve
name: Transport the generated matrix to job outputs
shell: python
run: |
import json
import os
from pathlib import Path
matrix_path = Path(
"src/build/redis-evidence/control/redis-readiness-matrix.json"
)
matrix = json.loads(matrix_path.read_text(encoding="utf-8"))
if matrix["releaseQualification"] != "NOT_CLAIMED":
raise SystemExit("resolver control artifact must not claim release qualification")
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
stream.write(
"selected="
+ json.dumps(matrix["selected"], separators=(",", ":"))
+ "\n"
)
stream.write(
"candidates="
+ json.dumps(
matrix["implementedCandidates"], separators=(",", ":")
)
+ "\n"
)
stream.write(f"selected_count={matrix['selectedCount']}\n")
stream.write(
"sentinel_required="
+ str(matrix["topologyJobs"]["sentinel"]).lower()
+ "\n"
)
stream.write(
"cluster_required="
+ str(matrix["topologyJobs"]["cluster"]).lower()
+ "\n"
)
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-readiness-control
path: src/build/redis-evidence/control
if-no-files-found: error
retention-days: 30
redis-security:
needs: resolve-redis-readiness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisSecurityTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-security-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-sentinel:
needs: resolve-redis-readiness
if: ${{ needs.resolve-redis-readiness.outputs.sentinel_required == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisSentinelTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-sentinel-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-cluster:
needs: resolve-redis-readiness
if: ${{ needs.resolve-redis-readiness.outputs.cluster_required == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisClusterTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-cluster-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-fault:
needs: resolve-redis-readiness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisFaultTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-fault-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-compatibility:
needs: resolve-redis-readiness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisCompatibilityTest --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-compatibility-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
selected-card-readiness:
needs: resolve-redis-readiness
if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.resolve-redis-readiness.outputs.selected) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew ${{ matrix.readinessTask }} --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-selected-${{ matrix.cardId }}
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 30
redis-all-candidates:
needs: resolve-redis-readiness
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- id: redis-tests
working-directory: src
run: ./gradlew redisAllImplementedCandidates --no-daemon --stacktrace
- id: redis-evidence-sanitizer
if: always()
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
with:
name: redis-all-candidates-evidence
path: src/adapter/outbound/cache-redis/build/redis-evidence
if-no-files-found: error
retention-days: 14
redis-production-readiness:
needs:
- resolve-redis-readiness
- selected-card-readiness
if: ${{ always() && needs.resolve-redis-readiness.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Require the exact selected matrix result
shell: python
env:
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
run: |
import os
selected_count_text = os.environ["SELECTED_COUNT"]
selected_job_result = os.environ["SELECTED_JOB_RESULT"]
if not selected_count_text.isdecimal():
raise SystemExit("selected_count must be a non-negative integer")
selected_count = int(selected_count_text)
expected_result = "skipped" if selected_count == 0 else "success"
if selected_job_result != expected_result:
raise SystemExit(
"selected-card-readiness result mismatch: "
f"count={selected_count}, expected={expected_result}, "
f"actual={selected_job_result}"
)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
name: redis-readiness-control
path: ${{ runner.temp }}/redis-readiness/control
- if: >-
${{
needs.resolve-redis-readiness.outputs.selected_count != '0'
&& needs.selected-card-readiness.result == 'success'
}}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
pattern: redis-selected-*
path: ${{ runner.temp }}/redis-readiness/selected
- name: Record the downloaded selected artifact inventory
shell: python
env:
GITHUB_RUN_ID: ${{ github.run_id }}
SELECTED_JSON: ${{ needs.resolve-redis-readiness.outputs.selected }}
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
REDIS_SELECTED_DIRECTORY: ${{ runner.temp }}/redis-readiness/selected
REDIS_CI_RESULT_FILE: ${{ runner.temp }}/redis-readiness/redis-ci-result.json
run: |
import json
import os
from pathlib import Path
selected = json.loads(os.environ["SELECTED_JSON"])
if not isinstance(selected, list):
raise SystemExit("selected matrix must be a JSON array")
expected_names = sorted(
"redis-selected-" + entry["cardId"] for entry in selected
)
if len(expected_names) != int(os.environ["SELECTED_COUNT"]):
raise SystemExit("selected_count does not match the selected matrix")
if len(expected_names) != len(set(expected_names)):
raise SystemExit("selected matrix contains duplicate artifact names")
selected_directory = Path(os.environ["REDIS_SELECTED_DIRECTORY"])
actual_names = (
sorted(path.name for path in selected_directory.iterdir() if path.is_dir())
if selected_directory.is_dir()
else []
)
if actual_names != expected_names:
raise SystemExit(
"downloaded selected artifact inventory mismatch: "
f"expected={expected_names}, actual={actual_names}"
)
result = {
"schemaVersion": 1,
"runId": os.environ["GITHUB_RUN_ID"],
"selectedCount": len(expected_names),
"selectedJobResult": os.environ["SELECTED_JOB_RESULT"],
"selectedArtifactNames": actual_names,
}
result_path = Path(os.environ["REDIS_CI_RESULT_FILE"])
result_path.parent.mkdir(parents=True, exist_ok=True)
result_path.write_text(
json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n",
encoding="utf-8",
)
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count == '0' }}
working-directory: src
run: >-
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
--no-daemon --stacktrace
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
working-directory: src
run: >-
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
-PredisEvidenceDirectory=${{ runner.temp }}/redis-readiness/selected
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
--no-daemon --stacktrace
+190
View File
@@ -0,0 +1,190 @@
# Redis SDK topology evidence.
#
# The lanes in infra/redis-sdk answer what the deterministic in-memory gateway cannot — Sentinel
# promotion behaviour, Cluster redirects, ACL coverage. docs/redis/support-matrix.md records which
# lane produced which evidence, and RedisSupportMatrixTest refuses an evidence claim that does not
# name the test class behind it.
#
# Three cadences, because the cost and the question differ:
#
# pull_request standalone only, current supported version. The cheapest lane that can still
# catch "this change cannot talk to a real Redis at all". A PR gate that starts
# three topologies is a PR gate people learn to ignore.
# schedule the full supported-version x topology matrix, nightly. This is where Sentinel
# promotion and Cluster redirect evidence comes from.
# workflow_dispatch one lane on demand, for reproducing a specific failure.
#
# A release candidate uses the nightly matrix run for its tag: `release-candidate` selects the full
# matrix on demand so an RC does not have to wait for the next scheduled run.
#
# Each lane has its own endpoint. A sentinel is not a data node and a cluster node is not the whole
# cluster, so the address, port, and (for Sentinel) the monitored primary's name are per-lane rather
# than one hardcoded 6379 that happens to be right for standalone only.
#
# The Gradle task is fail-closed on its own account: an unknown mode, a missing endpoint, a lane
# with no tagged test class, and a run that executed zero tests are all errors. This workflow does
# not need to re-check those, but it does have to keep the evidence, which is why every run uploads
# the JUnit XML together with the commit SHA, the server version and the resolved image digest. An
# evidence artifact that cannot say which image produced it is not evidence.
name: redis-sdk-topology
on:
pull_request:
paths:
- "src/adapter/outbound/cache-redis/**"
- "infra/redis-sdk/**"
- ".github/workflows/redis-sdk-topology.yml"
schedule:
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
- cron: "30 2 * * *"
workflow_dispatch:
inputs:
topology:
description: standalone, sentinel, cluster, tls, or release-candidate for the full matrix
required: true
default: standalone
type: choice
options: [standalone, sentinel, cluster, tls, release-candidate]
redis_version:
description: server version tag
required: true
default: "7.4"
type: string
permissions:
contents: read
jobs:
# The matrix is computed rather than duplicated per trigger, so adding a supported version is one
# edit and no trigger can silently keep testing an old set.
lanes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.select.outputs.matrix }}
steps:
- id: select
run: |
set -euo pipefail
case "${{ github.event_name }}" in
pull_request)
matrix='{"include":[{"topology":"standalone","redis_version":"7.4"}]}'
;;
schedule)
matrix='{"include":[
{"topology":"standalone","redis_version":"7.2"},
{"topology":"standalone","redis_version":"7.4"},
{"topology":"standalone","redis_version":"8.2"},
{"topology":"sentinel","redis_version":"7.2"},
{"topology":"sentinel","redis_version":"7.4"},
{"topology":"sentinel","redis_version":"8.2"},
{"topology":"cluster","redis_version":"7.2"},
{"topology":"cluster","redis_version":"7.4"},
{"topology":"cluster","redis_version":"8.2"},
{"topology":"tls","redis_version":"7.4"},
{"topology":"tls","redis_version":"8.2"}]}'
;;
*)
if [ "${{ inputs.topology }}" = "release-candidate" ]; then
matrix='{"include":[
{"topology":"standalone","redis_version":"7.2"},
{"topology":"standalone","redis_version":"7.4"},
{"topology":"standalone","redis_version":"8.2"},
{"topology":"sentinel","redis_version":"7.2"},
{"topology":"sentinel","redis_version":"7.4"},
{"topology":"sentinel","redis_version":"8.2"},
{"topology":"cluster","redis_version":"7.2"},
{"topology":"cluster","redis_version":"7.4"},
{"topology":"cluster","redis_version":"8.2"},
{"topology":"tls","redis_version":"7.4"},
{"topology":"tls","redis_version":"8.2"}]}'
else
matrix='{"include":[{"topology":"${{ inputs.topology }}","redis_version":"${{ inputs.redis_version }}"}]}'
fi
;;
esac
printf 'matrix=%s\n' "$(printf '%s' "$matrix" | tr -d '\n ')" >> "$GITHUB_OUTPUT"
topology-evidence:
needs: lanes
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Start the topology
env:
REDIS_VERSION: ${{ matrix.redis_version }}
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" up -d --wait
- name: Record the image digest
id: image
run: |
set -euo pipefail
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
# reproduced once the tag moves.
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
"redis:${{ matrix.redis_version }}" 2>/dev/null || echo 'unresolved')"
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
- name: Run the topology contracts
working-directory: src
run: |
set -euo pipefail
case '${{ matrix.topology }}' in
standalone) port=6379; extra='' ;;
sentinel) port=27010; extra='-Predis.topology.master=skeleton' ;;
cluster) port=7100; extra='' ;;
# The TLS lane's CA is generated at start-up, so the trust material is extracted from
# the lane rather than checked in. A checked-in key is a secret in the repository
# however loudly the file is named "test".
tls)
port=6390
docker compose -f ../infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt "$RUNNER_TEMP/redis-lane-ca.pem"
extra="-Predis.topology.trust-material=$RUNNER_TEMP/redis-lane-ca.pem"
;;
*) echo "unknown topology"; exit 1 ;;
esac
./gradlew :adapter:outbound:cache-redis:redisTopologyTest --console=plain \
-Predis.topology.host=localhost \
-Predis.topology.port="$port" \
-Predis.topology.mode='${{ matrix.topology }}' \
$extra
- name: Write the evidence manifest
if: always()
run: |
set -euo pipefail
out=src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest
mkdir -p "$out"
cat > "$out/evidence-manifest.txt" <<MANIFEST
commit=${{ github.sha }}
workflow_run=${{ github.run_id }}
trigger=${{ github.event_name }}
topology=${{ matrix.topology }}
redis_version=${{ matrix.redis_version }}
image_digest=${{ steps.image.outputs.digest }}
MANIFEST
- name: Preserve the evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: redis-topology-${{ matrix.topology }}-${{ matrix.redis_version }}
path: |
src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest/**
src/adapter/outbound/cache-redis/build/reports/tests/redisTopologyTest/**
if-no-files-found: error
retention-days: 90
- name: Stop the topology
if: always()
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" down -v
+3 -2
View File
@@ -50,7 +50,8 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
## Gradle 정책 권위
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
Gradle path, 허용 production project dependency edge
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
membership
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
architecture-wide verification task
@@ -101,7 +102,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성은
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
`src/**/CLAUDE.md`를 함께 읽는다.
+3 -2
View File
@@ -22,7 +22,8 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
## Gradle policy authorities
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
paths, Gradle paths, and allowed production project dependency edges.
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
memberships of both composition roots.
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
verification tasks.
@@ -57,7 +58,7 @@ families; the nearest `src/**/CLAUDE.md` owns local rules.
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
Read its `gradle_path` and `allowed_dependencies` from
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
## Layer workflow
+17
View File
@@ -56,6 +56,23 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
### 프로파일별 데이터스토어
`bootstrap`은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 `local` 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다.
```bash
cd src
./gradlew :app-bootstrap:bootRun
```
| 프로파일 | 데이터스토어 | 스키마 소유자 |
| --- | --- | --- |
| `local` (bootRun 기본) | H2 in-memory | Hibernate `create-drop` |
| `dev` | PostgreSQL | Flyway |
| `prod` | PostgreSQL | Flyway |
`local`은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 [src/app-bootstrap/src/main/resources/](src/app-bootstrap/src/main/resources/)의 `application-{local,dev,prod}.yml`이, 상세 설명은 [src/README.md](src/README.md)가 소유합니다.
## 새 프로젝트로 시작하기
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
+14 -3
View File
@@ -8,8 +8,9 @@
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
# - Wires the app environment to point at the local DB.
# - Keeps read-only filesystem and memory limits from the base compose.
# - Does NOT expose the DB port publicly; app and db communicate on the
# internal `caskeleton-local` network only.
# - Publishes the DB on the loopback interface only, so a host-side run
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
# containerised app reaches over the internal `caskeleton-local` network.
# =============================================================================
services:
@@ -59,7 +60,17 @@ services:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
# No host port: startup Flyway runs in the app container over the internal network.
# The containerised app reaches this over the internal network and needs no host port. A
# host-side run does: src/.env is the dotenv source bootRun reads, and its committed
# APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
# that default named an address nothing in the repository provisioned, so every bootRun died in
# the startup migration phase with a connection refusal.
#
# Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
# nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
# host keeps its conventional port.
ports:
- "127.0.0.1:5433:5432"
networks:
- caskeleton-local
healthcheck:
+19
View File
@@ -53,6 +53,20 @@ services:
tmpfs:
- /tmp:mode=1777,size=128m
- /var/tmp/heap:mode=1777,size=512m
# ---- Fileserver storage volume ------------------------------------------
# A named volume, not a tmpfs and not the read-only root. The Fileserver platform's default
# storage root is /var/lib/backend/files, and with a read-only root and no mount there was
# nowhere on the image it could legally write: enabling the capability failed on its first
# upload rather than at startup. The volume is declared unconditionally because a volume
# nobody writes to costs nothing, while a missing one costs an outage.
#
# Ownership: the image runs as uid/gid 1000 (see src/Dockerfile). Docker initialises a fresh
# named volume from the image path's ownership, so the directory is created in the image with
# that owner; a pre-existing volume or a host bind mount must be chowned to 1000:1000 by the
# operator, or every write is refused with a permission error the application reports as
# STORAGE_UNAVAILABLE.
volumes:
- fileserver-data:/var/lib/backend/files
# ---- Memory limit (D4) --------------------------------------------------
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
mem_limit: 512m
@@ -78,3 +92,8 @@ services:
start_period: 60s
retries: 3
restart: unless-stopped
volumes:
# Survives container replacement, which is the point: published content outlives the process
# that wrote it. Back this with real storage in any deployment that keeps files.
fileserver-data:
+176
View File
@@ -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).
+213
View File
@@ -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.
+105
View File
@@ -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 |
+128
View File
@@ -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.
+74
View File
@@ -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.
+66
View File
@@ -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.
+83
View File
@@ -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.
+82
View File
@@ -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
```
+228
View File
@@ -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.
+179
View File
@@ -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
+64
View File
@@ -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.
+121
View File
@@ -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.
+55
View File
@@ -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.
+47
View File
@@ -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.
- [ ] H1H4 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.
+87
View File
@@ -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/testkit/java/**/testkit` | The design forbids production modules depending on the testkit; a source set whose dependencies are declared only on the test configurations gives the same guarantee without a new Gradle project. It is its own source set rather than part of `test` because three lanes consume it — `test`, `httpClientPerformanceTest` and `jmh` — and reaching into `sourceSets.test.output` from `jmh` compiled under Gradle but could not be modelled by an IDE, which classifies a source set as test source only when a `Test` task runs its output and forbids main source from reading test source. `PlatformClasses` excludes the source set's output so the boundary rules keep meaning production classes. |
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` (`testkit` 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.
+71
View File
@@ -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.
+75
View File
@@ -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.
+52
View File
@@ -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.
+88
View File
@@ -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` |
+49
View File
@@ -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 |
+75
View File
@@ -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.
+159
View File
@@ -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 | R1R2 | 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 | R1R2 | 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 | R1R2 | 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 | R1R2 | 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 | R1R2 | yes | no | retention mandatory at creation |
| `extensions/probabilistic` | 8.0 | all | R1R2 | 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.
+62
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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는
+3 -1
View File
@@ -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 -1
View File
@@ -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 1324 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 |
| 1819 | 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 |
| 2425 | JSON, Search, Time Series, Probabilistic extensions | **Done** against the in-memory gateway; no real-module evidence |
| 2627 | 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 1017 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.28.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 1819 — 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 2425 — 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 2627 — 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.**
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.
@@ -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 12 |
| `SameNameButDifferent` | 9 | Qualify the two Redis nested enum types in section 2 |
| `DefaultCharset` | 9 | Explicit UTF-8 test data in sections 12 |
| `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 12 |
| `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
+25
View File
@@ -0,0 +1,25 @@
# Fileserver Superpowers Package
## 포함 파일
- `fileserver-platform-design.md` — Fileserver 플랫폼 설계 확정안
- `fileserver-platform-implementation-plan.md` — 33개 TDD 작업으로 분해한 구현 계획
- `VALIDATION.md` — 문서 정적 검증 결과
- `validate_fileserver_docs.py` — 검증 재실행 스크립트
## 저장소 배치 위치
```text
docs/superpowers/specs/2026-08-07-fileserver-platform-design.md
docs/superpowers/plans/2026-08-07-fileserver-platform-implementation-plan.md
```
## 실행 순서
1. 실제 Backend Skeleton 구조와 root package를 대조한다.
2. 설계서의 모듈 경계를 저장소에 반영한다.
3. 구현 계획 Task 1부터 순서대로 실행한다.
4. 각 Task에서 실패 테스트를 확인한 뒤 구현한다.
5. Milestone A~D마다 전체 검증 Gate를 실행한다.
실행에는 `superpowers:subagent-driven-development` 방식이 권장된다.
@@ -0,0 +1,43 @@
# Fileserver Superpowers 문서 검증
**결과:** PASS
## 파일
- `fileserver-platform-design.md` — 1893 lines, 59904 bytes, SHA-256 `ee7b21277b254b9606a9ec6e34118a10fba3abbe818b43cbce9ae832102411e6`
- `fileserver-platform-implementation-plan.md` — 3422 lines, 131608 bytes, SHA-256 `9a443852ab3a7e4a2232c1b443d4cb8d3478a4954d70510173d3e0ac1d3d2125`
## 검증 항목
- [x] **fileserver-platform-design.md exists** — /mnt/data/fileserver-platform-design.md
- [x] **fileserver-platform-implementation-plan.md exists** — /mnt/data/fileserver-platform-implementation-plan.md
- [x] **design title** — True
- [x] **plan header** — required Superpowers header
- [x] **design code fences** — count=94
- [x] **plan code fences** — count=416
- [x] **design placeholder scan** — hits=[]
- [x] **plan placeholder scan** — hits=[]
- [x] **design section coverage** — missing=[]
- [x] **design topic: MVC** — missing=[]
- [x] **design topic: WebFlux** — missing=[]
- [x] **design topic: local/PVC/NFS** — missing=[]
- [x] **design topic: content/metadata separation** — missing=[]
- [x] **design topic: upload** — missing=[]
- [x] **design topic: download** — missing=[]
- [x] **design topic: publish** — missing=[]
- [x] **design topic: security** — missing=[]
- [x] **design topic: resumable** — missing=[]
- [x] **design topic: observability** — missing=[]
- [x] **blocking core port leakage** — hits=[]
- [x] **task count** — count=33
- [x] **task numbering** — numbers=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]
- [x] **task block completeness** — {}
- [x] **unique create paths** — {}
- [x] **plan scope coverage** — missing=[]
- [x] **no Redis carryover** — search term=redis
- [x] **no deprecated nginx token design** — token mapper removed
## 검증 범위의 한계
- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.
- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
from __future__ import annotations
from collections import Counter
from pathlib import Path
import hashlib
import json
import re
import sys
ROOT = Path('/mnt/data')
DESIGN = ROOT / 'fileserver-platform-design.md'
PLAN = ROOT / 'fileserver-platform-implementation-plan.md'
errors: list[str] = []
checks: list[tuple[str, bool, str]] = []
def add(name: str, ok: bool, detail: str) -> None:
checks.append((name, ok, detail))
if not ok:
errors.append(f'{name}: {detail}')
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
for path in (DESIGN, PLAN):
add(f'{path.name} exists', path.exists(), str(path))
if errors:
print('\n'.join(errors), file=sys.stderr)
raise SystemExit(1)
design = DESIGN.read_text(encoding='utf-8')
plan = PLAN.read_text(encoding='utf-8')
add('design title', design.startswith('# Fileserver Platform 설계서'), True.__str__())
add('plan header', plan.startswith('# Fileserver Platform Implementation Plan\n\n> **For agentic workers:**'), 'required Superpowers header')
add('design code fences', design.count('```') % 2 == 0, f"count={design.count('```')}")
add('plan code fences', plan.count('```') % 2 == 0, f"count={plan.count('```')}")
for label, text in [('design', design), ('plan', plan)]:
forbidden = [r'\bTBD\b', r'\bTODO\b', r'implement later', r'fill in details', r'Similar to Task']
hits = [p for p in forbidden if re.search(p, text, re.I)]
add(f'{label} placeholder scan', not hits, f'hits={hits}')
required_design_sections = [
'## 5. 지원 매트릭스',
'## 6. 전체 아키텍처',
'## 9. 상태 머신과 invariant',
'## 10. Metadata Store 설계',
'## 11. Content Store Port',
'## 12. Local Filesystem Adapter',
'## 14. Publish와 완료 처리',
'## 15. Upload Application 설계',
'## 19. HTTP API',
'## 20. Range와 Conditional Request',
'## 21. Spring MVC Adapter',
'## 22. Spring WebFlux Adapter',
'## 23. Nginx 전송 위임',
'## 24. 재개 가능한 업로드',
'## 27. 보안 정책',
'## 28. 다중 인스턴스와 NFS',
'## 30. 관측성',
'## 33. 테스트 전략',
'## 37. 완료 정의',
]
missing_sections = [s for s in required_design_sections if s not in design]
add('design section coverage', not missing_sections, f'missing={missing_sections}')
source_topics = {
'MVC': ['Spring MVC Adapter', 'MvcTransferExecutorProperties'],
'WebFlux': ['Spring WebFlux Adapter', 'DataBuffer'],
'local/PVC/NFS': ['Kubernetes PVC', 'NFSv4.1', 'Local Filesystem Adapter'],
'content/metadata separation': ['Content Store Port', 'Metadata Store 설계'],
'upload': ['Upload Application 설계', 'multipart', 'application/octet-stream'],
'download': ['Range와 Conditional Request', 'ETag', 'If-Range'],
'publish': ['ATOMIC_MOVE_REQUIRED', 'METADATA_POINTER', 'AmbiguousCompletionException'],
'security': ['traversal', 'symlink', 'READY gate'],
'resumable': ['tus 1.0 Stable', 'draft-12 Experimental'],
'observability': ['Metric', 'Trace', 'Audit'],
}
for topic, needles in source_topics.items():
missing = [n for n in needles if n not in design]
add(f'design topic: {topic}', not missing, f'missing={missing}')
# Core Port snippet must not expose adapter types.
port_match = re.search(r'### 11\.2 Blocking SPI\n(.*?)### 11\.3 Async SPI', design, re.S)
port_text = port_match.group(1) if port_match else ''
forbidden_port_types = ['java.nio.file.Path', 'org.springframework.core.io.Resource', 'DataBuffer', 'Flux<']
port_hits = [x for x in forbidden_port_types if x in port_text]
add('blocking core port leakage', bool(port_match) and not port_hits, f'hits={port_hits}')
# Task structure.
task_matches = list(re.finditer(r'^### Task (\d+):', plan, re.M))
task_numbers = [int(m.group(1)) for m in task_matches]
add('task count', len(task_numbers) == 33, f'count={len(task_numbers)}')
add('task numbering', task_numbers == list(range(1, 34)), f'numbers={task_numbers}')
missing_task_blocks: dict[int, list[str]] = {}
for idx, match in enumerate(task_matches):
end = task_matches[idx + 1].start() if idx + 1 < len(task_matches) else plan.find('\n## 3.', match.start())
segment = plan[match.start():end]
required = [
'**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:',
'**Step 3:', '**Step 4:', '**Step 5:', 'Expected:', 'git commit'
]
missing = [item for item in required if item not in segment]
if missing:
missing_task_blocks[int(match.group(1))] = missing
add('task block completeness', not missing_task_blocks, json.dumps(missing_task_blocks, ensure_ascii=False))
create_paths = re.findall(r'^- Create: `([^`]+)`', plan, re.M)
duplicates = {path: count for path, count in Counter(create_paths).items() if count > 1}
add('unique create paths', not duplicates, json.dumps(duplicates, ensure_ascii=False))
required_plan_topics = [
'Task 10: Storage capability probe',
'Task 11: Streaming append',
'Task 13: Atomic move와 metadata pointer publish',
'Task 18: HTTP Range',
'Task 21: Spring WebFlux raw·multipart upload',
'Task 23: Nginx `X-Accel-Redirect`',
'Task 26: 다중 인스턴스 writer lease',
'Task 27: tus 1.0 Stable',
'Task 28: HTTPbis resumable upload draft-12 Experimental',
'Task 29: HTTP Problem Detail과 보안 hardening',
'Task 32: Filesystem, HTTP, fault, performance Testkit',
'Task 33: CI matrix',
]
missing_plan_topics = [x for x in required_plan_topics if x not in plan]
add('plan scope coverage', not missing_plan_topics, f'missing={missing_plan_topics}')
add('no Redis carryover', 'redis' not in design.lower() and 'redis' not in plan.lower(), 'search term=redis')
add('no deprecated nginx token design', 'DelegatedPathToken' not in design + plan and 'opaque-token' not in design + plan, 'token mapper removed')
status = 'PASS' if not errors else 'FAIL'
report = ROOT / 'fileserver-superpowers-validation.md'
lines = [
'# Fileserver Superpowers 문서 검증',
'',
f'**결과:** {status}',
'',
'## 파일',
'',
f'- `{DESIGN.name}` — {len(design.splitlines())} lines, {len(design.encode())} bytes, SHA-256 `{sha256(DESIGN)}`',
f'- `{PLAN.name}` — {len(plan.splitlines())} lines, {len(plan.encode())} bytes, SHA-256 `{sha256(PLAN)}`',
'',
'## 검증 항목',
'',
]
for name, ok, detail in checks:
lines.append(f"- [{'x' if ok else ' '}] **{name}** — {detail}")
lines += [
'',
'## 검증 범위의 한계',
'',
'- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.',
'- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.',
]
report.write_text('\n'.join(lines) + '\n', encoding='utf-8')
print(json.dumps({
'status': status,
'errors': errors,
'checks': len(checks),
'design_lines': len(design.splitlines()),
'plan_lines': len(plan.splitlines()),
'task_count': len(task_numbers),
'report': str(report),
}, ensure_ascii=False, indent=2))
raise SystemExit(0 if not errors else 1)
+23
View File
@@ -0,0 +1,23 @@
# HTTP Client Superpowers 설계 패키지
이 패키지는 `Java/Spring 외부 HTTP Client 플랫폼 설계 심층 리서치`를 기반으로 작성한 설계서와 구현 계획서다.
## 파일
- `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
- `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
- `VALIDATION.md`
- `validate_httpclient_docs.py`
## 구현 기준
- Java 21
- Gradle Kotlin DSL
- 공통 API는 Spring Framework 6.2 기준
- Spring Framework 7.0 호환성 검증
- Apache HttpClient 5 + RestClient
- JDK HttpClient + RestClient
- Reactor Netty + WebClient
- Jetty HTTP/3 Experimental
실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과 정책 의미론은 유지한다.
@@ -0,0 +1,31 @@
# HTTP Client Superpowers 문서 검증
**검증 결과:** PASS
## 검증 항목
- 설계서 존재 및 최소 구조: PASS
- 구현 계획서 존재 및 최소 구조: PASS
- Task 번호 연속성: PASS
- Task별 Files·Interfaces·Step 1~5·Expected·Commit: PASS
- Markdown code fence 균형: PASS
- Placeholder scan: PASS
- 중복 Create 경로: PASS
- 핵심 설계 범위: PASS
- 핵심 구현 범위: PASS
## 통계
- explicitly forbidden signature documented: ApacheHttpClient nativeApacheClient()
- explicitly forbidden signature documented: HttpClient nativeJdkClient()
- explicitly forbidden signature documented: WebClient.Builder mutableBuilder()
- explicitly forbidden signature documented: RestClient.Builder mutableBuilder()
- design lines=1956, bytes=64493
- plan lines=3635, bytes=158401
- tasks=38, create_paths=306
## 결론
- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.
- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.
- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.
@@ -0,0 +1,148 @@
from pathlib import Path
import re
import sys
import zipfile
base = Path('/mnt/data')
design_path = base / 'httpclient-platform-design.md'
plan_path = base / 'httpclient-platform-implementation-plan.md'
errors = []
notes = []
def read(p):
if not p.exists():
errors.append(f'missing file: {p}')
return ''
return p.read_text(encoding='utf-8')
design = read(design_path)
plan = read(plan_path)
# Basic size and structure
if len(design.splitlines()) < 1200:
errors.append(f'design unexpectedly short: {len(design.splitlines())} lines')
if len(plan.splitlines()) < 2500:
errors.append(f'plan unexpectedly short: {len(plan.splitlines())} lines')
# Task continuity and task internals
matches = list(re.finditer(r'^### Task (\d+): (.+)$', plan, flags=re.M))
nums = [int(m.group(1)) for m in matches]
expected = list(range(1, (max(nums) if nums else 0) + 1))
if nums != expected:
errors.append(f'task numbers not continuous: {nums[:5]}...{nums[-5:] if nums else []}')
for i, m in enumerate(matches):
start = m.start()
end = matches[i+1].start() if i+1 < len(matches) else plan.find('\n## 3. Plan Self-Review Checklist', start)
if end == -1:
end = len(plan)
block = plan[start:end]
n = m.group(1)
for token in ['**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:']:
if token not in block:
errors.append(f'Task {n} missing {token}')
if 'git commit -m ' not in block:
errors.append(f'Task {n} missing commit command')
if 'Expected:' not in block:
errors.append(f'Task {n} missing expected result')
# Markdown fence balance
for name, text in [('design', design), ('plan', plan)]:
count = len(re.findall(r'^```', text, flags=re.M))
if count % 2:
errors.append(f'{name} has unbalanced code fences: {count}')
# Placeholder scan
patterns = {
'TBD': r'\bTBD\b',
'TODO': r'\bTODO\b',
'implement later': r'implement later',
'fill in': r'fill in',
'similar to task': r'similar to Task',
'placeholder': r'placeholder',
}
for name, text in [('design', design), ('plan', plan)]:
for label, pat in patterns.items():
if re.search(pat, text, flags=re.I):
errors.append(f'{name} contains placeholder pattern: {label}')
# Duplicate create path scan
create_paths = re.findall(r'^- Create: `([^`]+)`', plan, flags=re.M)
dupes = sorted({p for p in create_paths if create_paths.count(p) > 1})
if dupes:
errors.append(f'duplicate Create paths: {dupes}')
# Required design coverage
required_design_terms = [
'H1 Typed Service Client', 'H2 Generic Exchange', 'H3 Dynamic Target',
'ExecutionEvidence', 'BodyReplayability', 'OperationIdempotency',
'Named Client Profile', 'Apache HttpClient 5', 'Reactor Netty',
'Retry Coordinator', 'Circuit Breaker', 'Rate Limiter', 'Bulkhead',
'OAuth2', 'TLS', 'SSRF', 'Streaming', 'SSE', 'HTTP/3',
'Spring Framework 6.2', 'Spring 7', 'RestTemplate'
]
for term in required_design_terms:
if term not in design:
errors.append(f'design missing term: {term}')
required_plan_terms = [
'httpclient-core-api', 'httpclient-transport-apache', 'httpclient-transport-jdk',
'httpclient-transport-reactor-netty', 'httpclient-dynamic-target',
'httpclient-spring-boot-starter', 'HttpAmbiguousExecutionException',
'first response byte', 'DNS/IP Pinning', 'SingleFlightTokenLoader',
'httpClientStableContractTest', 'spring62CompatibilityTest',
'spring70CompatibilityTest'
]
for term in required_plan_terms:
if term not in plan:
errors.append(f'plan missing term: {term}')
# Core API should not deliberately expose native clients in design signatures.
for forbidden_signature in [
'ApacheHttpClient nativeApacheClient()',
'HttpClient nativeJdkClient()',
'WebClient.Builder mutableBuilder()',
'RestClient.Builder mutableBuilder()'
]:
# These appear in an explicit "do not provide" code block. Note rather than fail.
if forbidden_signature in design:
notes.append(f'explicitly forbidden signature documented: {forbidden_signature}')
# Record task count and file counts
notes.append(f'design lines={len(design.splitlines())}, bytes={len(design.encode())}')
notes.append(f'plan lines={len(plan.splitlines())}, bytes={len(plan.encode())}')
notes.append(f'tasks={len(nums)}, create_paths={len(create_paths)}')
report = base / 'httpclient-superpowers-validation.md'
status = 'PASS' if not errors else 'FAIL'
report_text = [
'# HTTP Client Superpowers 문서 검증', '',
f'**검증 결과:** {status}', '',
'## 검증 항목', '',
f'- 설계서 존재 및 최소 구조: {"PASS" if design else "FAIL"}',
f'- 구현 계획서 존재 및 최소 구조: {"PASS" if plan else "FAIL"}',
f'- Task 번호 연속성: {"PASS" if nums == expected else "FAIL"}',
f'- Task별 Files·Interfaces·Step 1~5·Expected·Commit: {"PASS" if not any("Task " in e for e in errors) else "FAIL"}',
f'- Markdown code fence 균형: {"PASS" if not any("code fences" in e for e in errors) else "FAIL"}',
f'- Placeholder scan: {"PASS" if not any("placeholder" in e for e in errors) else "FAIL"}',
f'- 중복 Create 경로: {"PASS" if not dupes else "FAIL"}',
f'- 핵심 설계 범위: {"PASS" if not any("design missing" in e for e in errors) else "FAIL"}',
f'- 핵심 구현 범위: {"PASS" if not any("plan missing" in e for e in errors) else "FAIL"}',
'', '## 통계', ''
]
report_text += [f'- {note}' for note in notes]
if errors:
report_text += ['', '## 오류', ''] + [f'- {e}' for e in errors]
else:
report_text += ['', '## 결론', '',
'- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.',
'- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.',
'- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.']
report.write_text('\n'.join(report_text) + '\n', encoding='utf-8')
print(status)
for note in notes:
print(note)
for e in errors:
print('ERROR:', e)
sys.exit(0 if not errors else 1)
@@ -0,0 +1,127 @@
# Storage certification job.
#
# A PersistentVolumeClaim is not a filesystem contract. Whether an atomic rename, a same-file-store
# guarantee, or symlink refusal actually holds depends on the CSI driver, the StorageClass, the
# access mode, the backend, and the mount options — so this job records all five alongside the probe
# result. A certification without that tuple is not transferable to another cluster.
#
# The job writes a machine-readable result to the claim itself so the evidence lives with the volume
# it describes.
#
# kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
# kubectl logs job/fileserver-pvc-certification
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fileserver-certification
labels:
app.kubernetes.io/name: fileserver
app.kubernetes.io/component: certification
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
# Left unset on purpose: the certification is only meaningful for the class it actually ran on,
# so the operator names it explicitly rather than inheriting a cluster default.
storageClassName: ""
---
apiVersion: batch/v1
kind: Job
metadata:
name: fileserver-pvc-certification
labels:
app.kubernetes.io/name: fileserver
app.kubernetes.io/component: certification
spec:
backoffLimit: 0
template:
metadata:
labels:
app.kubernetes.io/name: fileserver
app.kubernetes.io/component: certification
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
containers:
- name: certify
image: eclipse-temurin:21-jdk
env:
- name: FILESERVER_STORAGE_ROOT
value: /var/lib/backend/files
- name: KUBERNETES_VERSION
valueFrom:
fieldRef:
fieldPath: metadata.annotations['certification.fileserver/kubernetes-version']
- name: CSI_DRIVER
valueFrom:
fieldRef:
fieldPath: metadata.annotations['certification.fileserver/csi-driver']
- name: STORAGE_CLASS
valueFrom:
fieldRef:
fieldPath: metadata.annotations['certification.fileserver/storage-class']
- name: ACCESS_MODE
value: ReadWriteOnce
command:
- /bin/bash
- -c
- |
set -euo pipefail
ROOT="${FILESERVER_STORAGE_ROOT}"
mkdir -p "${ROOT}/staging" "${ROOT}/content"
# Atomic rename within one file store is the property the publish path depends on.
echo probe > "${ROOT}/staging/probe"
if mv "${ROOT}/staging/probe" "${ROOT}/content/probe" 2>/dev/null; then
ATOMIC_MOVE=true
else
ATOMIC_MOVE=false
fi
# Same device means a rename is a metadata operation rather than a copy.
STAGING_DEV=$(stat -c %d "${ROOT}/staging")
CONTENT_DEV=$(stat -c %d "${ROOT}/content")
[ "${STAGING_DEV}" = "${CONTENT_DEV}" ] && SAME_STORE=true || SAME_STORE=false
# O_EXCL create is what makes a publish create-only rather than an overwrite.
if (set -o noclobber; echo x > "${ROOT}/content/excl") 2>/dev/null; then
ATOMIC_CREATE=true
else
ATOMIC_CREATE=false
fi
cat > "${ROOT}/certification-result.json" <<RESULT
{
"kubernetesVersion": "${KUBERNETES_VERSION:-unknown}",
"csiDriver": "${CSI_DRIVER:-unknown}",
"storageClass": "${STORAGE_CLASS:-unknown}",
"accessMode": "${ACCESS_MODE}",
"backend": "$(stat -f -c %T "${ROOT}")",
"mountOptions": "$(findmnt -no OPTIONS --target "${ROOT}" || echo unknown)",
"atomicMove": ${ATOMIC_MOVE},
"sameFileStore": ${SAME_STORE},
"atomicCreate": ${ATOMIC_CREATE}
}
RESULT
cat "${ROOT}/certification-result.json"
# Fail closed: a volume that cannot publish atomically must not be certified silently.
[ "${SAME_STORE}" = "true" ] || { echo "staging and content are on different stores"; exit 1; }
volumeMounts:
- name: storage
mountPath: /var/lib/backend/files
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: storage
persistentVolumeClaim:
claimName: fileserver-certification
+61
View File
@@ -0,0 +1,61 @@
# Network-filesystem certification environment.
#
# A local filesystem cannot reproduce the failures this environment exists to test: a rename whose
# acknowledgement is lost, a stale file handle after the server restarts, and a client that keeps
# writing across a network cut. Those are precisely the cases where "the write failed, retry it" is
# the wrong conclusion, so they are certified against a real NFS server rather than a mock.
#
# Opt in with FILESERVER_NFS_TESTS=true; the default test run does not start this.
#
# docker compose -f infra/fileserver/nfs/compose.yml up -d
# FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
#
# To exercise the ambiguity paths:
# docker compose -f infra/fileserver/nfs/compose.yml restart nfs-server # stale handles
# docker network disconnect fileserver-nfs <client> # lost responses
services:
nfs-server:
image: erichough/nfs-server:2.2.1
container_name: fileserver-nfs-server
privileged: true
environment:
NFS_EXPORT_0: "/exports *(rw,sync,no_subtree_check,no_root_squash,fsid=0)"
NFS_VERSION: "4.2"
NFS_LOG_LEVEL: DEBUG
volumes:
- nfs-exports:/exports
ports:
- "2049:2049"
networks:
- fileserver-nfs
healthcheck:
test: ["CMD", "rpcinfo", "-t", "localhost", "nfs", "4"]
interval: 5s
timeout: 3s
retries: 10
nfs-client:
image: eclipse-temurin:21-jdk
container_name: fileserver-nfs-client
privileged: true
depends_on:
nfs-server:
condition: service_healthy
# hard,intr is the correct production mount: a soft mount turns a slow server into a silent
# short write, which is exactly the corruption the design refuses to accept.
command: >
bash -c "mkdir -p /mnt/fileserver &&
mount -t nfs4 -o hard,timeo=50,retrans=2 nfs-server:/ /mnt/fileserver &&
tail -f /dev/null"
volumes:
- ../../..:/workspace:ro
networks:
- fileserver-nfs
volumes:
nfs-exports:
networks:
fileserver-nfs:
name: fileserver-nfs
+67
View File
@@ -0,0 +1,67 @@
# Fileserver front-proxy configuration.
#
# The application authorizes every download and then hands the transfer to Nginx with
# X-Accel-Redirect. Two properties make that safe, and both are enforced here rather than assumed:
#
# 1. /__files/ is `internal`, so it is reachable ONLY through an internal redirect the application
# issued. A direct request from a client returns 404 and never touches the content root.
# 2. The application never emits an absolute path. It emits a relative URI below /__files/, and
# the alias below is the only place that prefix becomes a filesystem location.
#
# Keep `alias` in sync with the storage root's content directory. A mismatch is a startup
# misconfiguration, not a runtime fallback: the application's startup validator checks that the
# internal mapping was proven before it accepts traffic.
worker_processes auto;
events {
worker_connections 4096;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
sendfile_max_chunk 2m;
tcp_nopush on;
keepalive_timeout 65;
# Uploads stream through to the application; buffering a large body to disk here would double
# the write and defeat the streaming upload path.
proxy_request_buffering off;
client_max_body_size 0;
server {
listen 8080;
# Public API. Everything, including download authorization, is decided by the application.
location / {
proxy_pass http://app:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# The application must never see a client-supplied delegation header: it would let a
# caller name an arbitrary internal object.
proxy_set_header X-Accel-Redirect "";
}
# Internal transfer location. Not reachable from outside; see property (1) above.
location /__files/ {
internal;
alias /srv/files/content/;
sendfile on;
sendfile_max_chunk 2m;
# Uploaded content is never trusted to describe itself.
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition $upstream_http_content_disposition always;
add_header Cache-Control $upstream_http_cache_control always;
add_header ETag $upstream_http_etag always;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
# HTTP Client Platform — local test topology
The suites drive these dependencies through Testcontainers and in-process fixtures, so nothing here
is required to run `./gradlew :adapter:outbound:httpclient:test`. These files exist for the nightly
lane and for reproducing a failure locally with the same images and ports CI uses.
| Directory | Purpose | Used by |
|---|---|---|
| `toxiproxy/` | TCP fault injection (latency, reset, bandwidth) | `httpClientFailureInjectionTest` |
| `tls/` | how the TLS and mTLS material is produced | TLS and mTLS suites |
| `proxy/` | forward proxy with CONNECT and proxy authentication | proxy contract suite |
| `oauth2/` | token endpoint behaviour under contention | OAuth2 suites |
+12
View File
@@ -0,0 +1,12 @@
# OAuth2 fixture
`OAuth2Fixture` exposes a token endpoint backed by the same deterministic fixture server as the rest
of the suite.
It counts token requests, which is what makes design §20.3's single-flight guarantee provable rather
than assumed: a hundred genuinely concurrent callers must produce exactly one token request. It can
also issue rotating token values, so a stale cached token is detectable, and queue a failure status
to exercise the refresh-failure path.
The token endpoint is configured as its own Named Client Profile, separate from the upstream it
issues tokens for.
+12
View File
@@ -0,0 +1,12 @@
# Forward proxy fixture
`ProxyFixture` runs an in-process forward proxy so the proxy lane needs no external service.
| Factory | Behaviour |
|---|---|
| `ProxyFixture.openProxy()` | accepts CONNECT and tunnels to the target |
| `ProxyFixture.authenticatingProxy(user, password)` | answers `407` until `Proxy-Authorization` matches |
The fixture records every request line and every `Proxy-Authorization` value it saw, which is what
lets the suite prove design §24.3: proxy credentials never appear on the target request, and a proxy
CONNECT failure is reported as `HttpProxyException` rather than as a target TLS failure.
+16
View File
@@ -0,0 +1,16 @@
# TLS fixtures
Certificates are generated **in process** by `TlsFixture`, not checked in. A committed private key
is a private key that leaks, and design §21.2 forbids key material in the repository.
`TlsFixture` produces, from a throwaway CA created per test run:
| Fixture | Purpose |
|---|---|
| `TlsFixture.trusted()` | a server certificate valid for the loopback host |
| `TlsFixture.hostnameMismatch()` | a certificate whose SAN does not match the connection host |
| `TlsFixture.expired()` | an already-expired certificate |
| `clientHandshake(true)` | client key material for the mTLS lane |
All three failure cases must classify as permanent (design §21.3) — never retried, never downgraded
to plaintext.
+17
View File
@@ -0,0 +1,17 @@
# Fault-injection topology for the HTTP Client Platform failure suite (design §28.1, §28.3).
#
# The suite normally drives Toxiproxy through Testcontainers. This compose file exists for the
# nightly lane and for reproducing a failure locally with the exact same image and ports.
services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.9.0
container_name: httpclient-toxiproxy
ports:
- "8474:8474" # control API
- "18080:18080" # proxied upstream: plaintext
- "18443:18443" # proxied upstream: TLS
healthcheck:
test: ["CMD", "/toxiproxy-cli", "list"]
interval: 5s
timeout: 3s
retries: 10
-134
View File
@@ -1,134 +0,0 @@
# Disposable Redis qualification lab
This directory owns the lifecycle contract for the isolated three-node k3s lab. It does not contain
Redis workloads, credentials, certificates, or qualification evidence.
## Fixed topology
| Instance | CPU | Memory | Disk | Role |
| --- | ---: | ---: | ---: | --- |
| `ca-redis-lab-server` | 2 | 3G | 12G | k3s server |
| `ca-redis-lab-agent-1` | 2 | 2560M | 12G | k3s agent |
| `ca-redis-lab-agent-2` | 2 | 2560M | 12G | k3s agent |
The lab uses pod CIDR `10.52.0.0/16`, service CIDR `10.53.0.0/16`, and context
`ca-redis-lab`. `versions.env` pins the k3s version and Multipass image. Traefik and ServiceLB are
disabled.
## Safety model
All state, rendered cloud-init, kubeconfigs, tokens, and raw observations are mode-restricted
beneath the ignored `src/build/redis-lab` directory. Every canonical ancestor from the repository
root through `src/build/redis-lab`, plus runtime children, is validated before observation or
mutation; a symlink or real-path escape fails closed. The lifecycle never exports `KUBECONFIG`,
merges a kubeconfig, or writes the user's default kubeconfig.
Host observation and lab access deliberately use different explicit targets:
- host read-only queries copy the default kubeconfig into
`src/build/redis-lab/observations/host-kubeconfig` and use its unchanged original context;
- lab read-only queries use `src/build/redis-lab/kubeconfig` and exact context `ca-redis-lab`.
This split preserves the host context identity while ensuring no kubectl call relies on an implicit
target. Host kubectl mutations are not part of the lifecycle. The observation-only host copy is
removed after fingerprint and CIDR observation on success and every handled failure path.
Each exact name gets a private mode-`0600` rendered cloud-init file beneath
`src/build/redis-lab/cloud-init`. It writes only the non-secret ownership marker
`RUN_ID|VM_NAME` to `/var/lib/ca-redis-lab/ownership` as `root:root` mode `0600`; launch uses only
that rendered file.
Each name is then atomically reserved as `PENDING` in `run.state` before its bounded launch. The
state starts with an exact per-run identity, and each `PENDING`/`CREATED`/`RECONCILE` entry carries
that same identity. A successful launch becomes `CREATED` only after a bounded
`multipass exec <name> -- sudo cat /var/lib/ca-redis-lab/ownership` returns the exact marker.
Timeout, launch error, missing/foreign marker, signal, promotion failure, or uncertain cleanup
enters `RECONCILE`.
Cleanup transitions a recorded entry to `RECONCILE`, bounded-polls the exact instance and marker,
and issues `multipass delete --purge <exact-name>` only after the marker matches. It atomically
removes only an entry whose delete succeeded. A late-created matching instance is deleted; an
absent instance, unreadable marker, mismatched/foreign marker, or failed delete is retained as a
tombstone and fails closed without an unproven delete. Existing instance-bearing state blocks a
new `preflight`, `up`, or `run`; a rejected new run does not clean the prior run, and `down` is the
retry/reconciliation entry point. Existing
allowlisted names without owned state cause `up` to stop before reservation/launch and are never
adopted or deleted. Wildcards, `--all`, global purge, and discovered-instance deletion are
forbidden.
The lifecycle lock is nonblocking and exclusive. External children close its descriptor by
default, including detached infrastructure descendants and `run --` commands; only lock
acquisition retains descriptor 9.
Multipass list/launch/info/exec/transfer/delete, installation/join, and kubectl calls have fixed
time bounds. `up` succeeds only after the exact server and two agents all report `Ready=True`
within the bounded poll budget; incomplete or not-ready inventory enters marker-proven run-owned
cleanup.
The k3s runtime is amd64-only and fail-closed in this slice. `versions.env` pins the immutable
release URL and exact SHA-256 for `v1.33.3+k3s1`. The lifecycle performs a bounded host download,
verifies the digest, transfers the binary to each exact VM, verifies the transferred digest and
reported binary version inside each VM, and only then installs/starts it. It does not execute a
network installer or a `curl | sh` pipeline.
The generated lab kubeconfig is accepted only in the pinned single-cluster/single-context/
single-user block grammar. A tracked AWK state machine has one explicit transition for every
allowlisted line and publishes no output until the complete document reaches its exact final
state. It rejects missing, duplicate, reordered, unknown, whitespace-altered, quoted, tagged, or
explicit keys; anchors, aliases, merge keys, tabs, CRLF, document markers, trailing content, and
all flow collections except exact `preferences: {}`. Only the exact cluster/context/user identity,
`current-context`, and loopback API server are rewritten. CA data, client certificate/key data,
and an optional canonical namespace are byte-preserved.
Rendering uses a same-directory `kubeconfig.next`, applies mode `0600`, and replaces the
destination only after render and permission success. The renderer must be a readable regular
non-symlink file at its canonical tracked path, and both destination paths are protected by the
runtime symlink contract. Renderer, permission, or move failure removes both candidate and
destination, performs no lab `kubectl`, and enters exact marker-proven current-run cleanup.
Assigned Service ClusterIPs cannot prove the host service CIDR. When a host kubeconfig exists,
callers must supply one or more canonical, comma- or space-separated IPv4 CIDRs through
`REDIS_LAB_HOST_SERVICE_CIDRS`. Missing, malformed, or overlapping input fails before launch:
```bash
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
infra/redis-lab/bin/redis-lab preflight
```
## Commands
The real lifecycle is for a trusted local or dedicated runner only:
```bash
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab preflight
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab up
infra/redis-lab/bin/redis-lab down
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab run -- command
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
infra/redis-lab/bin/redis-lab run --retain-on-failure -- command
```
`run` establishes its cleanup obligation before entering the inner `up`, keeps it through the
post-up/pre-command handoff and user command, then tears down after command success or failure and
compares the canonical pre/post host fingerprints after cleanup. A successful direct `up` retains
the lab by design. Local `--retain-on-failure` intentionally leaves the recorded lab for diagnosis
and skips an isolation-success claim; `CI=true` rejects that option before launch.
The blocking contract is VM-free:
```bash
cd src
./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain
```
It injects fake infrastructure commands. Hosted CI must run only this contract, never the real lab.
The contract executes a copied lifecycle in
`src/build/redis-lab-contract/repository`, seals `PATH` to explicit fakes/safe wrappers, and compares
a byte-level snapshot proving it did not modify the real repository's `src/build/redis-lab`. It
also exercises direct/run signal cleanup, rendered-child symlink rejection, successful and
late-create marker proof, absent/foreign-marker tombstones, second-run state preservation,
CREATED cleanup uncertainty, rejected-run preservation of prior `CREATED` and `RECONCILE` state,
the post-up/pre-command signal handoff, the canonical kubeconfig mutation matrix, missing/symlinked
renderer rejection, fail-closed `.next`/permission/move publication, and infrastructure/user
background-child lock non-inheritance. This is deterministic fake-runtime evidence only; it is not
live Multipass, k3s, kubectl, network, or host-isolation qualification.
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
#cloud-config
package_update: false
package_upgrade: false
ssh_pwauth: false
disable_root: true
write_files:
- path: /etc/sysctl.d/90-ca-redis-lab.conf
owner: root:root
permissions: "0644"
content: |
net.ipv4.ip_forward=1
runcmd:
- [mkdir, -p, /etc/rancher/k3s]
- [sysctl, --system]
-194
View File
@@ -1,194 +0,0 @@
BEGIN {
state = "start"
invalid = 0
output_count = 0
if (target != "ca-redis-lab") {
invalid = 1
}
}
function remember(line) {
output[++output_count] = line
}
function is_credential_line(line, prefix, value) {
if (index(line, prefix) != 1) {
return 0
}
value = substr(line, length(prefix) + 1)
return value ~ /^[A-Za-z0-9+\/=_-]+$/
}
function is_namespace_line(line, value) {
if (index(line, " namespace: ") != 1) {
return 0
}
value = substr(line, length(" namespace: ") + 1)
return value ~ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
}
{
if (invalid) {
next
}
if (index($0, "\t") != 0 || index($0, "\r") != 0 ||
$0 ~ /^[[:space:]]*(---|\.\.\.)([[:space:]]|$)/) {
invalid = 1
next
}
if ($0 != "preferences: {}" &&
(index($0, "{") != 0 || index($0, "}") != 0 ||
index($0, "[") != 0 || index($0, "]") != 0)) {
invalid = 1
next
}
if (state == "start" && $0 == "apiVersion: v1") {
api_version_count += 1
state = "apiVersion"
remember($0)
next
}
if (state == "apiVersion" && $0 == "clusters:") {
clusters_count += 1
state = "clusters"
remember($0)
next
}
if (state == "clusters" && $0 == "- cluster:") {
cluster_item_count += 1
state = "cluster-item"
remember($0)
next
}
if (state == "cluster-item" &&
is_credential_line($0, " certificate-authority-data: ")) {
ca_data_count += 1
state = "ca-data"
remember($0)
next
}
if (state == "ca-data" &&
$0 == " server: https://127.0.0.1:6443") {
server_count += 1
state = "server"
remember(" server: https://" address ":6443")
next
}
if (state == "server" && $0 == " name: default") {
cluster_name_count += 1
state = "cluster-name"
remember(" name: " target)
next
}
if (state == "cluster-name" && $0 == "contexts:") {
contexts_count += 1
state = "contexts"
remember($0)
next
}
if (state == "contexts" && $0 == "- context:") {
context_item_count += 1
state = "context-item"
remember($0)
next
}
if (state == "context-item" && $0 == " cluster: default") {
context_cluster_count += 1
state = "context-cluster"
remember(" cluster: " target)
next
}
if (state == "context-cluster" && is_namespace_line($0)) {
namespace_count += 1
state = "optional-namespace"
remember($0)
next
}
if ((state == "context-cluster" || state == "optional-namespace") &&
$0 == " user: default") {
context_user_count += 1
state = "context-user"
remember(" user: " target)
next
}
if (state == "context-user" && $0 == " name: default") {
context_name_count += 1
state = "context-name"
remember(" name: " target)
next
}
if (state == "context-name" && $0 == "current-context: default") {
current_context_count += 1
state = "current-context"
remember("current-context: " target)
next
}
if (state == "current-context" && $0 == "kind: Config") {
kind_count += 1
state = "kind"
remember($0)
next
}
if (state == "kind" && $0 == "preferences: {}") {
preferences_count += 1
state = "preferences"
remember($0)
next
}
if (state == "preferences" && $0 == "users:") {
users_count += 1
state = "users"
remember($0)
next
}
if (state == "users" && $0 == "- name: default") {
user_name_count += 1
state = "user-name"
remember("- name: " target)
next
}
if (state == "user-name" && $0 == " user:") {
user_body_count += 1
state = "user-body"
remember($0)
next
}
if (state == "user-body" &&
is_credential_line($0, " client-certificate-data: ")) {
client_cert_count += 1
state = "client-cert"
remember($0)
next
}
if (state == "client-cert" &&
is_credential_line($0, " client-key-data: ")) {
client_key_count += 1
state = "client-key"
remember($0)
next
}
invalid = 1
}
END {
if (invalid || state != "client-key" ||
api_version_count != 1 || clusters_count != 1 ||
cluster_item_count != 1 || ca_data_count != 1 ||
server_count != 1 || cluster_name_count != 1 ||
contexts_count != 1 || context_item_count != 1 ||
context_cluster_count != 1 || namespace_count > 1 ||
context_user_count != 1 || context_name_count != 1 ||
current_context_count != 1 || kind_count != 1 ||
preferences_count != 1 || users_count != 1 ||
user_name_count != 1 || user_body_count != 1 ||
client_cert_count != 1 || client_key_count != 1) {
exit 1
}
for (line_number = 1; line_number <= output_count; line_number += 1) {
print output[line_number]
}
}
@@ -1,20 +0,0 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://192.0.2.10:6443
name: ca-redis-lab
contexts:
- context:
cluster: ca-redis-lab
namespace: team-default
user: ca-redis-lab
name: ca-redis-lab
current-context: ca-redis-lab
kind: Config
preferences: {}
users:
- name: ca-redis-lab
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
@@ -1,19 +0,0 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://192.0.2.10:6443
name: ca-redis-lab
contexts:
- context:
cluster: ca-redis-lab
user: ca-redis-lab
name: ca-redis-lab
current-context: ca-redis-lab
kind: Config
preferences: {}
users:
- name: ca-redis-lab
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
@@ -1,19 +0,0 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: preserve-default-ca-canary
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: default
name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
client-certificate-data: preserve-default-client-cert-canary
client-key-data: preserve-default-client-key-canary
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
K3S_VERSION=v1.33.3+k3s1
MULTIPASS_IMAGE=24.04
K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s
K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc
+148
View File
@@ -0,0 +1,148 @@
# Redis SDK topology lanes
These lanes exist to answer the questions the deterministic in-memory gateway cannot: how Lettuce
actually behaves during a Sentinel promotion, what a Cluster resharding does to an in-flight
command, and whether the ACL accounts grant exactly what the SDK issues.
All four have now run on Redis 7.4 and the evidence is recorded in
`docs/redis/support-matrix.md`. `.github/workflows/redis-sdk-topology.yml` runs the standalone lane
on any pull request that touches the Redis leaf, the full supported-version x topology matrix
nightly, and the same matrix on demand for a release candidate.
TLS is a lane of that matrix rather than something to wire up by hand. It is `tls`, not a
deployment mode: its shape is standalone and what it qualifies is the transport, so
`redisTopologyTest` maps the lane name to `standalone` for the tests and keeps the tag filter and
the required trust material on the lane.
## The TLS lane
`tls/compose.yml` is the standalone shape with the transport swapped. The plaintext port is turned
off entirely (`--port 0`), which is the only configuration that proves anything: a lane accepting
both would let a client that failed to negotiate TLS fall back silently and still pass.
Certificates are generated at start-up into a named volume rather than checked in — a private key
in the repository is a private key in the repository, however the file is named — and they last a
day, so a stale lane fails visibly instead of drifting.
```bash
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/tls/compose.yml up -d --wait
# The client needs the generated CA; copy it out of the volume first.
docker compose -f infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt /tmp/redis-lane-ca.pem
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=127.0.0.1 -Predis.topology.port=6390 \
-Predis.topology.mode=tls -Predis.topology.trust-material=/tmp/redis-lane-ca.pem
```
The lane refuses to run without `redis.topology.trust-material`. A TLS lane that trusts anything
qualifies nothing, so "no CA configured" is an error rather than a client with verification off.
## The ACL fixture
`acl/all-accounts.acl` provisions the accounts every lane uses. Two things about it matter, and
neither can be written in the file itself — **Redis refuses to start if an `aclfile` contains a
comment line**, so the whole file is directives and the explanation lives here.
`user default off` is the first line and is deliberate. Redis ships `default` enabled and
passwordless; while it is on, every restriction in the remaining accounts can be bypassed by simply
not authenticating, which makes the fixture decorative. Disabling it is what forces a client — and
the compose healthchecks — to pick a named account.
Every named account carries a real password — `>fixture-application`, `>fixture-advanced`, and so
on. They were `nopass`, which was the more dangerous kind of wrong: an account that accepts any
password made every assertion about authentication pass for the same reason a typo would have, so
the lane's coverage of AUTH, rotation and secret wiring was indistinguishable from no coverage.
`LiveRedisCompositionTest` now presents a wrong password on purpose and requires `WRONGPASS`, which
is only a meaningful assertion because the accounts enforce one.
The passwords are fixture values in a throwaway container and are **not** a deployment template: a
real deployment resolves each account's credential through `secret://` and never writes one into
configuration.
The accounts are also split by role, because that is how the SDK uses them. `ca-skeleton-application`
runs ordinary data commands and cannot execute a script; `ca-skeleton-application-advanced` holds
`SCRIPT LOAD` and `EVALSHA` and nothing else needs to. That separation is real rather than
decorative: `LiveRedisSemanticPortsTest` runs the rate limiter without the advanced account and
requires it to come back `Unavailable`.
## Running one
Each lane has its own endpoint, because the address a client is given is not the same kind of thing
in each topology. Standalone declares a data node; Sentinel declares a *sentinel*, from which the
primary is resolved and re-resolved when it is promoted; Cluster declares any node, from which the
rest of the topology is discovered.
```bash
# Standalone
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/standalone/compose.yml up -d --wait
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost -Predis.topology.port=6379 -Predis.topology.mode=standalone
# Sentinel — the port is a sentinel, and the monitored primary has to be named
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/sentinel/compose.yml up -d --wait
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost -Predis.topology.port=27010 \
-Predis.topology.mode=sentinel -Predis.topology.master=skeleton
# Cluster — `up --wait` waits for the `ready` gate, not just for six servers that answer PING.
# Slot assignment finishes after the nodes are healthy, and a client that connects in between sees
# CLUSTERDOWN for reasons that have nothing to do with the SDK.
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/cluster/compose.yml up -d --wait
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost -Predis.topology.port=7100 -Predis.topology.mode=cluster
```
Tear a lane down with `docker compose -f infra/redis-sdk/<lane>/compose.yml down -v`.
| Lane | Ports | Notes |
| --- | --- | --- |
| standalone | 6379 | bridge network, published port |
| sentinel | primary 7010, replica 7011, sentinels 2701027012 | host network |
| cluster | nodes 71007105, bus 1710017105 | host network; `ready` gates on `cluster_state:ok` |
| tls | 6390 | published port, no plaintext port at all; CA generated per run |
## Why the Sentinel and Cluster lanes use host networking
Neither topology proxies. Sentinel answers `SENTINEL get-master-addr-by-name` with the address it
monitors and the client dials that itself; a cluster client reads `CLUSTER SHARDS` and connects to
every node it names. On a bridge network those are container-internal addresses, so a client on the
host resolves a topology it cannot reach — and after a promotion it resolves a *different* one it
also cannot reach. Sharing the host network namespace makes the address the topology advertises the
address the client can use, which is the difference between testing the SDK and testing Docker's
network.
That is also why their ports are fixed rather than parameterised: the addresses are written into
Sentinel's and the cluster's own configuration at creation time, and a lane whose two halves can
disagree fails for reasons that are not the SDK's.
## Selection is by lane, not by hand
`redisTopologyTest` derives its JUnit tag expression from the declared mode: `redis-topology &
lane-<mode>`. A promotion test is meaningless without sentinels and a cross-slot test is meaningless
without a cluster, but expressing that as a runtime assumption would turn "the lane was never
started" into a green skip. Selecting by tag keeps it fail-closed — what a mode cannot prove is not
selected, and what is selected must pass.
The lane also fails closed on its endpoint: selecting `redisTopologyTest` without host, port, and
mode (and `redis.topology.master` on the Sentinel lane) is an error, never a skip. A topology test
that silently passes because it did not connect is worse than no topology test.
## `min-replicas-to-write` on the Sentinel lane
The Sentinel lane sets `min-replicas-to-write 1` and `min-replicas-max-lag 1`, and this is not
incidental configuration. Without them the lane measured a promotion in which the superseded primary
kept answering `+OK` for eleven seconds after it had been replaced: **2,086 writes acknowledged to
the caller and then discarded**, with exactly one command failing. With them the same promotion lost
one write and refused 2,020 with `NOREPLICAS`, which the SDK reports as a definite, non-ambiguous
failure a caller can act on.
Any deployment where an acknowledgement is supposed to mean something has to set these. See
`docs/redis/support-matrix.md` for the full record.
## ACL accounts
`acl/` holds one file per `CommandAccess` level. They are deliberately narrower than the SDK's own
rules, so a mistake in the SDK is still refused by the server — the account is the last boundary and
a permit never widens it.
Every lane loads the same file on every data node. Accounts are enforced per node, so "they exist on
one node" is not evidence that a topology enforces them.

Some files were not shown because too many files have changed in this diff Show More