chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
* text=auto
*.java text eol=lf
*.gradle text eol=lf
*.properties text eol=lf
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.md text eol=lf
gradlew text eol=lf
gradlew.bat text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.jar binary
*.zip binary
*.gz binary
+13
View File
@@ -0,0 +1,13 @@
# Replace this template owner when forking the repository. CODEOWNERS is effective only when the
# forge supports it and default-branch protection requires code-owner review.
/.trivyignore.yaml @DongHyeonka
/.github/ @DongHyeonka
/renovate.json @DongHyeonka
/flaky-quarantine.yaml @DongHyeonka
# Public security and compatibility baselines require an explicit maintainer review.
/docs/security/ @DongHyeonka
/src/config/architecture/modules.json @DongHyeonka
*.approved.json @DongHyeonka
*.approved.txt @DongHyeonka
+289
View File
@@ -0,0 +1,289 @@
# Current repository CI controls. This file lists only mechanisms and jobs that exist in this
# checkout. Build/release supply-chain, image, signing, provenance, SBOM, and tag-release jobs are
# intentionally absent until their later bounded reconstruction.
#
# Fields:
# release_blocking: true, false, or conditional
# mechanism: gradle-custom-task, gradle-plugin-task, contract-test, workflow-job,
# or delegated-pending
# ref: task, plugin@task, repository-relative test path below src/, or workflow job id
# workflow/job: canonical workflow and job that execute or represent the control
# execution: check (through Gradle check), explicit (named in the job), or job
gates:
- id: format-lint
release_blocking: true
mechanism: gradle-plugin-task
ref: com.diffplug.spotless@spotlessCheck
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: unit-and-contract-tests
release_blocking: true
mechanism: gradle-plugin-task
ref: java@test
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
ref: verifyCleanArchitectureDependencies
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: environment-contract
release_blocking: true
mechanism: gradle-custom-task
ref: verifyEnvKeys
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: one-type-per-file
release_blocking: true
mechanism: gradle-custom-task
ref: verifyOneTypePerFile
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: readme-command-drift
release_blocking: true
mechanism: gradle-custom-task
ref: verifyReadmeCommands
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: trivy-suppression-governance
release_blocking: true
mechanism: gradle-custom-task
ref: verifyTrivyignore
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: quarantine-sunset
release_blocking: true
mechanism: gradle-custom-task
ref: verifyQuarantineSunset
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: public-path-snapshot
release_blocking: true
mechanism: gradle-custom-task
ref: verifyPublicPathSnapshot
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: dependency-locks
release_blocking: true
mechanism: gradle-custom-task
ref: verifyDependencyLocks
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: architecture-contract-test
release_blocking: true
mechanism: contract-test
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: sample-off
release_blocking: true
mechanism: gradle-custom-task
ref: sampleOffTest
workflow: ci-quality-gates.yml
job: sample-off
execution: explicit
- id: gate-matrix-lint
release_blocking: true
mechanism: workflow-job
ref: gate-matrix-lint
workflow: ci-quality-gates.yml
job: gate-matrix-lint
execution: job
- id: redis-sdk
release_blocking: true
mechanism: workflow-job
ref: redis-sdk
workflow: ci-quality-gates.yml
job: redis-sdk
execution: job
- id: jpa-candidate-evidence
release_blocking: true
mechanism: workflow-job
ref: jpa-candidate-evidence
workflow: ci-quality-gates.yml
job: jpa-candidate-evidence
execution: job
- id: jpa-r2-evidence
release_blocking: conditional
mechanism: workflow-job
ref: jpa-r2-evidence
workflow: jpa-r2-evidence.yml
job: jpa-r2-evidence
execution: job
- id: quality-release-gate
release_blocking: true
mechanism: workflow-job
ref: release-gate
workflow: ci-quality-gates.yml
job: release-gate
execution: job
- id: flaky-quarantine
release_blocking: false
mechanism: workflow-job
ref: quarantine
workflow: ci-quality-gates.yml
job: quarantine
execution: job
- id: dependency-review
release_blocking: conditional
mechanism: workflow-job
ref: dependency-review
workflow: dependency-vulnerability.yml
job: dependency-review
execution: job
- id: dependency-submission
release_blocking: false
mechanism: workflow-job
ref: dependency-submission
workflow: dependency-vulnerability.yml
job: dependency-submission
execution: job
- id: filesystem-vulnerability-scan
release_blocking: true
mechanism: workflow-job
ref: trivy-fs
workflow: dependency-vulnerability.yml
job: trivy-fs
execution: job
- id: documentation-links
release_blocking: conditional
mechanism: workflow-job
ref: lychee
workflow: link-check.yml
job: lychee
execution: job
- id: object-storage-minio-managed-contract
release_blocking: true
mechanism: gradle-custom-task
ref: objectStorageMinioContractTest
workflow: object-storage-qualification.yml
job: minio-managed-contract
execution: explicit
- id: poster-image-migration
release_blocking: true
mechanism: gradle-custom-task
ref: posterImageMigrationTest
workflow: object-storage-qualification.yml
job: poster-image-v7-migration
execution: explicit
- id: object-storage-minio-managed-fault
release_blocking: conditional
mechanism: gradle-custom-task
ref: objectStorageMinioFaultTest
workflow: object-storage-qualification.yml
job: minio-managed-fault
execution: explicit
- id: object-storage-aws-protected-qualification
release_blocking: conditional
mechanism: delegated-pending
ref: approval-gate-b
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
+18
View File
@@ -0,0 +1,18 @@
# GitHub dependency-review configuration. The workflow guards this GitHub-only API so other forges
# use the platform-neutral Trivy filesystem snapshot instead.
fail-on-severity: high
fail-on-scopes:
- runtime
# Template legal posture: deny strong and network copyleft for newly introduced dependencies.
# Forks must have their legal/security owner review this organization-specific list.
deny-licenses:
- GPL-2.0-only
- GPL-2.0-or-later
- GPL-3.0-only
- GPL-3.0-or-later
- AGPL-3.0-only
- AGPL-3.0-or-later
comment-summary-in-pr: never
@@ -0,0 +1,92 @@
# Dependency Vulnerability Policy
This policy is enforced by
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
[`dependency-review-config.yml`](dependency-review-config.yml),
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and
[`../renovate.json`](../renovate.json).
## Execution and platform boundary
Canonical workflow files live under `.github/workflows`. The current origin is Gitea and
server-side Actions is externally disabled; committing these controls does not enable or prove
remote execution. An administrator must enable Actions, provide compatible runners, configure
required checks, and require code-owner review separately.
External actions execute only by verified full commit SHA; inline comments retain the immutable
release tag for review. GitHub dependency review publishes check output but never writes a PR
summary comment, preserving the workflow-wide `contents: read` permission.
GitHub dependency review and dependency submission depend on GitHub.com APIs. They are guarded by
`github.server_url == 'https://github.com'` and intentionally skip on Gitea. `trivy-fs` is the
platform-neutral release-blocking snapshot and runs for pull requests, `main` pushes, daily
schedule, and manual dispatch.
The later supply-chain slice owns image builds/scans, SBOM, signing, provenance, tag release, and
retention. This policy does not claim those absent jobs.
## Severity and response
| Severity | CI posture | Target remediation |
| --- | --- | --- |
| KEV / Critical | block | 7 days or the CISA due date, whichever is sooner |
| High | block | 30 days |
| Medium | advisory | 90 days |
| Low | advisory | best effort |
High/Critical is the template's release threshold. The exact threshold and remediation targets are
team policy rather than an external mandate and should be reviewed when the template is adopted.
GitHub dependency review applies the same High threshold to newly introduced runtime dependencies.
The Trivy JSON snapshot is also compared with the CISA Known Exploited Vulnerabilities catalog.
Any intersection blocks regardless of Trivy severity. Before intersection, CI requires nonblank
catalog metadata, a positive integral declared count, a non-empty vulnerability array whose length
matches that count, and unique CVE-pattern `cveID` values. A missing, empty, malformed,
count-inconsistent, duplicate, or unreachable KEV feed fails closed; configure the `KEV_FEED_URL`
repository variable to an approved internal mirror when direct CISA access is unavailable.
Candidate extraction also validates Trivy's JSON first: the top level must be an object with a
non-empty `Results` array; each result is an object whose `Vulnerabilities` value is null or an
array; and every listed vulnerability has a nonblank string `VulnerabilityID`. Invalid scanner
output fails closed instead of becoming an empty candidate set.
## Scanner and network requirements
Trivy scans the committed filesystem and Gradle lockfiles. High/Critical findings exit non-zero;
Medium/Low findings are reported with exit zero. Scanner database or tool-download failures remain
failures rather than silently producing an empty result.
Runners need HTTPS egress to:
- the configured Trivy binary source (GitHub Releases by default);
- Trivy vulnerability databases (the scanner defaults, commonly OCI registries);
- jq releases, unless `JQ_DOWNLOAD_BASE_URL` points to an internal mirror;
- the CISA KEV feed, unless `KEV_FEED_URL` points to an internal mirror;
- GitHub action sources when the forge does not mirror actions.
Closed networks must mirror the pinned Trivy/jq artifacts and checksums, set
`TRIVY_DOWNLOAD_BASE_URL` and `JQ_DOWNLOAD_BASE_URL`, configure Trivy's documented database mirror
environment, and set `KEV_FEED_URL`. Mirror availability and freshness are operational
dependencies; stale mirrors can delay detection.
## Suppression governance
The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it
explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a
non-empty rationale, and a future expiry no more than 90 days away. `verifyTrivyignore` validates
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
Neither control substitutes for the other.
Do not use an ad-hoc ignore file or an inline scanner bypass. An expired suppression is removed or
renewed with fresh owner review and current evidence.
## Dependency update policy
Renovate is the checked-in security-update bot configuration. Patch/pin/digest security updates
may auto-merge only after every configured required check succeeds; minor and major updates require
human review. Dependency declarations and all 19 `gradle.lockfile` files must move together, and
`verifyDependencyLocks` remains release-blocking.
Bot alerts are not a transitive-dependency backstop on every forge. Use a Gradle constraint or
resolution rule for a vulnerable transitive dependency, refresh locks deliberately, and retain the
full-snapshot Trivy scan.
+18
View File
@@ -0,0 +1,18 @@
## What changed and why
<!-- Summarize the behavior, architecture boundary, or policy being changed. -->
## Verification
- [ ] I ran the focused test for each changed leaf.
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`.
- [ ] I did not add an unregistered production module dependency.
- [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification.
- [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days.
- [ ] Any quarantined test is registered in `flaky-quarantine.yaml` with a reason, issue, and
`quarantined_since` date.
- [ ] I documented checks that could not run and their remaining risk.
## Compatibility and operations
<!-- Note public API/config changes, migration needs, rollout/rollback impact, and external blockers. -->
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
readonly JQ_VERSION='1.8.1'
readonly JQ_SHA256_AMD64='020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d'
readonly JQ_SHA256_ARM64='6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4'
readonly DOWNLOAD_BASE_URL="${JQ_DOWNLOAD_BASE_URL:-https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}}"
: "${RUNNER_TEMP:?RUNNER_TEMP must be set by the CI runner}"
: "${GITHUB_PATH:?GITHUB_PATH must be set by the CI runner}"
architecture="${RUNNER_ARCH:-$(uname -m)}"
case "${architecture}" in
X64 | x86_64 | amd64)
asset='jq-linux-amd64'
expected_sha256="${JQ_SHA256_AMD64}"
;;
ARM64 | aarch64 | arm64)
asset='jq-linux-arm64'
expected_sha256="${JQ_SHA256_ARM64}"
;;
*)
printf '::error::install-jq: unsupported runner architecture: %s\n' "${architecture}" >&2
exit 1
;;
esac
install_dir="${RUNNER_TEMP}/jq-${JQ_VERSION}/bin"
destination="${install_dir}/jq"
mkdir -p "${install_dir}"
temporary="$(mktemp "${RUNNER_TEMP}/jq-${JQ_VERSION}.XXXXXX")"
trap 'rm -f "${temporary}"' EXIT
curl --fail --show-error --silent --location --retry 3 \
--proto '=https' --tlsv1.2 \
"${DOWNLOAD_BASE_URL}/${asset}" \
--output "${temporary}"
printf '%s %s\n' "${expected_sha256}" "${temporary}" | sha256sum -c -
chmod 0755 "${temporary}"
mv "${temporary}" "${destination}"
trap - EXIT
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
installed_version="$("${destination}" --version)"
if [[ "${installed_version}" != "jq-${JQ_VERSION}" ]]; then
printf '::error::install-jq: expected jq-%s, got %s\n' "${JQ_VERSION}" "${installed_version}" >&2
exit 1
fi
printf 'install-jq: %s installed under RUNNER_TEMP\n' "${installed_version}"
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
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
fi
records="$(
awk '
function flush() {
if (id != "") {
printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, blocking, mechanism, ref, workflow, job, execution
}
}
/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/ {
flush()
id=$0
sub(/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/, "", id)
blocking=mechanism=ref=workflow=job=execution=""
next
}
/^[[:space:]]+release_blocking:[[:space:]]*/ {
blocking=$0
sub(/^[[:space:]]+release_blocking:[[:space:]]*/, "", blocking)
next
}
/^[[:space:]]+mechanism:[[:space:]]*/ {
mechanism=$0
sub(/^[[:space:]]+mechanism:[[:space:]]*/, "", mechanism)
next
}
/^[[:space:]]+ref:[[:space:]]*/ {
ref=$0
sub(/^[[:space:]]+ref:[[:space:]]*/, "", ref)
next
}
/^[[:space:]]+workflow:[[:space:]]*/ {
workflow=$0
sub(/^[[:space:]]+workflow:[[:space:]]*/, "", workflow)
next
}
/^[[:space:]]+job:[[:space:]]*/ {
job=$0
sub(/^[[:space:]]+job:[[:space:]]*/, "", job)
next
}
/^[[:space:]]+execution:[[:space:]]*/ {
execution=$0
sub(/^[[:space:]]+execution:[[:space:]]*/, "", execution)
next
}
END { flush() }
' "${MATRIX}"
)"
declare -A seen_ids=()
declare -a failures=()
total=0
verified=0
delegated=0
job_body() {
local workflow_file="$1"
local job_id="$2"
awk -v target="${job_id}" '
$0 ~ "^ " target ":[[:space:]]*$" { inside=1; print; next }
inside && $0 ~ "^ [A-Za-z0-9_-]+:[[:space:]]*$" { exit }
inside { print }
' "${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))
if [[ -n "${seen_ids[${id}]:-}" ]]; then
failures+=("duplicate gate id '${id}'")
fi
seen_ids["${id}"]=1
if [[ -z "${blocking}" || -z "${mechanism}" || -z "${ref}" || -z "${workflow}" \
|| -z "${job}" || -z "${execution}" ]]; then
failures+=("gate '${id}' has an empty required field")
continue
fi
if [[ ! "${blocking}" =~ ^(true|false|conditional)$ ]]; then
failures+=("gate '${id}' has invalid release_blocking '${blocking}'")
fi
if [[ ! "${workflow}" =~ ^[A-Za-z0-9._-]+\.ya?ml$ || ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then
failures+=("gate '${id}' has an unsafe workflow or job identifier")
continue
fi
workflow_file="${REPO_ROOT}/.github/workflows/${workflow}"
if [[ ! -f "${workflow_file}" ]]; then
failures+=("gate '${id}' references missing workflow '.github/workflows/${workflow}'")
continue
fi
if ! grep -Eqs -- "^[[:space:]]{2}${job}:[[:space:]]*$" "${workflow_file}"; then
failures+=("gate '${id}' references missing job '${job}' in '${workflow}'")
continue
fi
case "${mechanism}" in
gradle-custom-task)
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
;;
gradle-plugin-task)
plugin="${ref%@*}"
task="${ref#*@}"
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 ! gradle_plugin_is_applied "${plugin}"; then
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
continue
fi
;;
contract-test)
if [[ "${ref}" == /* || "${ref}" == *".."* || ! -f "${REPO_ROOT}/src/${ref}" ]]; then
failures+=("gate '${id}' references missing or unsafe contract test 'src/${ref}'")
continue
fi
;;
workflow-job)
if [[ "${ref}" != "${job}" ]]; then
failures+=("gate '${id}' workflow-job ref '${ref}' must equal job '${job}'")
continue
fi
;;
delegated-pending)
delegated=$((delegated + 1))
printf "gate '%s': explicitly delegated-pending\n" "${id}"
continue
;;
*)
failures+=("gate '${id}' has unknown mechanism '${mechanism}'")
continue
;;
esac
case "${execution}" in
check)
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
failures+=("gate '${id}' expects Gradle check in job '${job}'")
continue
fi
if [[ "${mechanism}" == "gradle-custom-task" ]] \
&& ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \
--include='build.gradle'; then
failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check")
continue
fi
;;
explicit)
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
continue
fi
;;
job)
;;
*)
failures+=("gate '${id}' has unknown execution '${execution}'")
continue
;;
esac
verified=$((verified + 1))
done <<< "${records}"
if (( total != EXPECTED_GATE_COUNT )); then
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
fi
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
"${total}" "${verified}" "${delegated}"
if (( ${#failures[@]} > 0 )); then
printf '::error::gate-matrix-lint: %d drift(s) found\n' "${#failures[@]}" >&2
for failure in "${failures[@]}"; do
printf ' - %s\n' "${failure}" >&2
done
exit 1
fi
printf 'gate-matrix-lint: OK\n'
+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'
+197
View File
@@ -0,0 +1,197 @@
name: ci-quality-gates
on:
pull_request:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
quality-gates:
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
readonly snapshot='docs/security/public-paths-snapshot.txt'
if [[ ! -s "${snapshot}" ]]; then
echo "::error::${snapshot} is missing or empty. CI must not let verifyPublicPathSnapshot create its own first-run baseline."
exit 1
fi
if ! git ls-files --error-unmatch "${snapshot}" >/dev/null 2>&1; then
echo "::error::${snapshot} exists locally but is not committed."
exit 1
fi
- 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: Check quality, public paths, and dependency locks
working-directory: src
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify the application without the sample fixture
working-directory: src
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
gate-matrix-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Verify the gate matrix against the repository
run: bash .github/scripts/verify-gate-matrix.sh
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
# 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
:shared-contract:edgeRateLimitContractTest
:adapter:outbound:cache-redis:check
verifyCleanArchitectureDependencies
verifyEnvKeys
verifyPublicPathSnapshot
verifyConfigurationPropertiesProcessor
--no-daemon --stacktrace
jpa-candidate-evidence:
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: Produce zero-skip JPA candidate manifests
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
--no-daemon
--stacktrace
- name: Retain content-addressed JPA candidate manifests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: jpa-candidate-evidence-${{ github.sha }}
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
if-no-files-found: error
retention-days: 14
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
quarantine:
runs-on: ubuntu-latest
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: Run quarantined tests as an advisory signal
working-directory: src
run: ./gradlew quarantineTest --no-daemon
release-gate:
needs:
- quality-gates
- sample-off
- gate-matrix-lint
- redis-sdk
- jpa-candidate-evidence
if: always()
runs-on: ubuntu-latest
steps:
- name: Require every current blocking job to succeed
env:
QUALITY_RESULT: ${{ needs.quality-gates.result }}
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
REDIS_RESULT: ${{ needs.redis-sdk.result }}
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
run: |
set -euo pipefail
for result in \
"${QUALITY_RESULT}" \
"${SAMPLE_OFF_RESULT}" \
"${MATRIX_RESULT}" \
"${REDIS_RESULT}" \
"${JPA_CANDIDATE_RESULT}"; do
if [[ "${result}" != "success" ]]; then
echo "::error::release-gate: required job result was ${result}"
exit 1
fi
done
echo "release-gate: all current blocking quality jobs succeeded."
@@ -0,0 +1,188 @@
name: dependency-vulnerability
on:
pull_request:
push:
branches: ["main"]
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# The compare API exists only on GitHub.com. Trivy remains the full-snapshot backstop elsewhere.
dependency-review:
if: github.event_name == 'pull_request' && github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Review newly introduced dependencies
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # actions/dependency-review-action@v4.8.0
with:
config-file: ./.github/dependency-review-config.yml
# The submission API is also GitHub.com-only and is not required for the platform-neutral scan.
dependency-submission:
if: github.event_name == 'push' && github.server_url == 'https://github.com'
runs-on: ubuntu-latest
permissions:
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Submit the resolved Gradle dependency graph
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
with:
build-root-directory: src
trivy-fs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Install pinned Trivy under RUNNER_TEMP
env:
TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }}
run: |
set -euo pipefail
readonly TRIVY_VERSION='0.71.2'
readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460'
readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc'
readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}"
case "${RUNNER_ARCH:-X64}" in
X64)
asset_arch='64bit'
expected_sha256="${TRIVY_SHA256_AMD64}"
;;
ARM64)
asset_arch='ARM64'
expected_sha256="${TRIVY_SHA256_ARM64}"
;;
*)
echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}"
exit 1
;;
esac
install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}"
archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz"
mkdir -p "${install_dir}"
curl --fail --show-error --silent --location --retry 3 \
--proto '=https' --tlsv1.2 \
"${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \
--output "${archive}"
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c -
tar -xzf "${archive}" -C "${install_dir}" trivy
chmod 0755 "${install_dir}/trivy"
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
- name: Install checksum-pinned jq
env:
JQ_DOWNLOAD_BASE_URL: ${{ vars.JQ_DOWNLOAD_BASE_URL }}
run: bash .github/scripts/install-jq.sh
- name: Block High and Critical vulnerabilities
run: |
trivy fs \
--scanners vuln,license \
--severity CRITICAL,HIGH \
--exit-code 1 \
--ignorefile .trivyignore.yaml \
.
- name: Report Medium and Low vulnerabilities
run: |
trivy fs \
--scanners vuln,license \
--severity MEDIUM,LOW \
--exit-code 0 \
--ignorefile .trivyignore.yaml \
.
- name: Produce the governed all-severity KEV input
run: |
trivy fs \
--scanners vuln \
--severity CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN \
--exit-code 0 \
--ignorefile .trivyignore.yaml \
--format json \
--output trivy-kev.json \
.
- name: Fail closed on any CISA KEV match
env:
CONFIGURED_KEV_FEED_URL: ${{ vars.KEV_FEED_URL }}
run: |
set -euo pipefail
readonly DEFAULT_KEV_FEED_URL='https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json'
readonly KEV_FEED_URL="${CONFIGURED_KEV_FEED_URL:-${DEFAULT_KEV_FEED_URL}}"
if ! curl --fail --show-error --silent --location --retry 3 \
--proto '=https' --tlsv1.2 "${KEV_FEED_URL}" --output kev.json; then
echo "::error::KEV feed unavailable; configure KEV_FEED_URL to an approved internal mirror"
exit 1
fi
if ! jq -e '
(.catalogVersion | type == "string" and test("\\S"))
and (.dateReleased | type == "string" and test("\\S"))
and (.count | type == "number")
and (.count > 0)
and (.count == (.count | floor))
and (.vulnerabilities | type == "array")
and ((.vulnerabilities | length) > 0)
and (.count == (.vulnerabilities | length))
and (all(
.vulnerabilities[];
(.cveID | type == "string" and test("^CVE-[0-9]{4}-[0-9]{4,}$"))
))
and (([.vulnerabilities[].cveID] | unique | length) == .count)
' kev.json >/dev/null; then
echo "::error::KEV feed is malformed, empty, count-inconsistent, or contains invalid/duplicate cveID values"
exit 1
fi
if ! jq -e '
(type == "object")
and (.Results | type == "array")
and ((.Results | length) > 0)
and (all(.Results[]; type == "object"))
and (all(
.Results[];
(.Vulnerabilities == null) or (.Vulnerabilities | type == "array")
))
and (all(
.Results[];
all(
.Vulnerabilities[]?;
(type == "object")
and (.VulnerabilityID | type == "string" and test("\\S"))
)
))
' trivy-kev.json >/dev/null; then
echo "::error::Trivy KEV input is malformed, empty, or contains an invalid VulnerabilityID"
exit 1
fi
jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID | select(type == "string")] | unique[]?' \
trivy-kev.json | sort -u > found-cves.txt
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
kev.json | sort -u > kev-cves.txt
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
if [[ -n "${hits}" ]]; then
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
printf '%s\n' "${hits}"
exit 1
fi
echo "KEV cross-check: no catalog match."
+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
+56
View File
@@ -0,0 +1,56 @@
name: jpa-r2-evidence
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
jpa-r2-evidence:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
JPA_EVIDENCE_PROFILE: r2
JPA_EVIDENCE_CI_JOB: >-
actions:${{ github.workflow }}:${{ github.run_id }}:${{ github.job }}
JPA_EVIDENCE_ARTIFACT_LOCATION: >-
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify the production-profile JPA R2 manifest DAG
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
-PjpaEvidenceProfile=r2
--no-daemon
--stacktrace
- name: Retain JPA R2 attempt manifests
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: jpa-r2-evidence-${{ github.sha }}-${{ github.run_id }}
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
if-no-files-found: error
retention-days: 30
+49
View File
@@ -0,0 +1,49 @@
name: link-check
on:
pull_request:
paths:
- "README.md"
- "src/README.md"
- "src/**/README.md"
- "src/**/CLAUDE.md"
- "docs/**/*.md"
- ".github/**/*.md"
- ".github/workflows/link-check.yml"
push:
branches: ["main"]
paths:
- "README.md"
- "src/README.md"
- "src/**/README.md"
- "src/**/CLAUDE.md"
- "docs/**/*.md"
- ".github/**/*.md"
- ".github/workflows/link-check.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lychee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Check committed documentation links
uses: lycheeverse/lychee-action@7cd0af4c74a61395d455af97419279d86aafaede # lycheeverse/lychee-action@v2.0.2
with:
args: >-
--no-progress
--root-dir .
README.md
src/README.md
'src/**/README.md'
'src/**/CLAUDE.md'
'docs/**/*.md'
'.github/**/*.md'
fail: true
@@ -0,0 +1,108 @@
name: object-storage-qualification
on:
pull_request:
schedule:
- cron: "23 3 * * 2"
workflow_dispatch:
inputs:
run_protected_aws:
description: Run the protected AWS sandbox qualification lane
required: true
default: false
type: boolean
permissions:
contents: read
env:
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
poster-image-v7-migration:
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run non-skipping Poster image migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
minio-managed-contract:
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run exact-release MinIO managed contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
minio-managed-fault:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run digest-pinned MinIO and Toxiproxy fault contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
aws-managed-common-subset:
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
environment: object-storage-aws-qualification
runs-on: ubuntu-latest
env:
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
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
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run protected AWS common-subset qualification
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --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
+2
View File
@@ -0,0 +1,2 @@
.vscode/
src/**/bin/
+1
View File
@@ -0,0 +1 @@
java temurin-21.0.11+10
+15
View File
@@ -0,0 +1,15 @@
# Structured Trivy suppression baseline.
#
# This repository-root file is the only CI suppression source. Every future entry must include:
# id: advisory, license, misconfiguration, or secret identifier
# statement: non-empty accepted-risk or false-positive rationale
# expired_at: future YYYY-MM-DD no more than 90 days from review
#
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate
# reviewer control. Every Trivy invocation must also name this file with
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
vulnerabilities: []
licenses: []
misconfigurations: []
secrets: []
+249
View File
@@ -0,0 +1,249 @@
# AGENTS.md
## 프로젝트 정체성
이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다.
기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 엔지니어링 작업 기록 게시판)에 격리한다. 새 프로젝트를 시작할 때는 도메인 이름, 패키지, 엔티티, 유스케이스를 교체할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다.
## Prime Directive
에이전트는 속도보다 아키텍처 보존을 우선한다.
동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다.
다음 HARD-STOP 8개 항목이 이 저장소의 정본(canonical) 로컬 정책 권위이자 SSOT다.
1. `domain-core`가 framework, transport, database, cloud 의존성을 가진다.
2. controller가 repository, Spring Data interface, persistence entity를 직접 사용한다.
3. inbound DTO가 `application-core` 또는 `domain-core`로 유출된다.
4. 비즈니스 규칙이 mapper, filter, configuration, settings, controller로 이동한다.
5. 프로젝트 의존성이 `src/config/architecture/modules.json` 또는 Gradle 의존성 검증을 위반한다.
6. 관련 검증 없이, 또는 실행하지 못한 이유를 밝히지 않고 완료를 주장한다.
7. 결론의 범위와 위험에 맞는 증거 없이 repository/corpus 전체 결론을 내린다.
8. 의미 있는 작업을 필수 LLM Wiki capture 또는 명시한 capture 차단 사유 없이 종료한다.
root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어긋나면 이 `AGENTS.md` 목록이
우선한다. 자동 강제 범위는 아래 Gradle 정책 권위와 ArchUnit/Test가 담당한다.
## Superpowers Workflow
이 프로젝트에서 에이전트는 관련 Superpowers 스킬을 먼저 확인하고, 작업 성격에 맞는 스킬을 사용한다. 사용자 지시와 이 `AGENTS.md`가 로컬 프로젝트의 최상위 규칙이며, Superpowers는 그 규칙을 실행하기 위한 작업 방식이다.
사용 가능한 주요 스킬과 트리거:
- `superpowers:using-superpowers`: 대화나 작업을 시작할 때 관련 스킬을 확인한다.
- `superpowers:brainstorming`: 기능 설계, 구조 변경, 동작 변경, 새 문서 정책 수립 전에 사용한다.
- `superpowers:writing-plans`: 승인된 설계가 있고 작업이 여러 단계로 나뉠 때 사용한다.
- `superpowers:executing-plans`: 작성된 계획을 현재 세션에서 순차 실행할 때 사용한다.
- `superpowers:subagent-driven-development`: 계획을 작업 단위로 나누어 독립 에이전트에게 맡길 때 사용한다.
- `superpowers:dispatching-parallel-agents`: 서로 독립적인 조사나 구현을 병렬로 진행할 때 사용한다.
- `superpowers:test-driven-development`: 기능 추가와 버그 수정을 테스트 우선으로 진행할 때 사용한다.
- `superpowers:systematic-debugging`: 버그, 실패한 테스트, 예상 밖 동작을 다룰 때 사용한다.
- `superpowers:verification-before-completion`: 완료, 수정됨, 통과함을 주장하기 전에 사용한다.
- `superpowers:requesting-code-review`: 의미 있는 구현을 마친 뒤 병합 또는 PR 전에 사용한다.
- `superpowers:receiving-code-review`: 리뷰 피드백을 적용하기 전에 사용한다.
- `superpowers:finishing-a-development-branch`: 구현과 검증이 끝난 브랜치를 정리할 때 사용한다.
- `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다.
- `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다.
## Gradle 정책 권위
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
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
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`
함께 읽는다. focused test는 registry의 `gradle_path`에서
`./gradlew <gradle-path>:test --console=plain` 형태로 파생한다. 파일 수만으로 위험을 판단하지 않고,
변경한 경계와 런타임·보안·데이터 영향에 맞춰 설계·리뷰·검증 강도를 높인다.
commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다.
## LLM Wiki 캡처 워크플로우
구현, 아키텍처, 빌드, 테스트, 런타임, 문서 워크플로우 변경처럼 의미 있는 작업을 끝낸 뒤에는 최종 응답 전에 LLM Wiki 기록을 갱신한다.
기준 vault:
```text
/home/donghyeon/workspace/ai-tool/llm-wiki-private/
```
에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다.
필수 순서:
1. `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/<branch-name>.md`를 생성하거나 갱신한다.
2. 구현 내용, 변경 파일, 의사결정, 검증 명령, 실패/차단 사항, 증거 등급을 branch-note에 기록한다.
3. 실제로 파생 자료가 있으면 다음 raw 문서를 생성하거나 갱신한다.
- `raw/errors/`: 오류, 실패한 테스트, 샌드박스/도구 문제, 재발 가능한 트러블슈팅
- `raw/interviews/`: 이 작업에서 정직하게 뽑을 수 있는 면접 질문
- `raw/blog-topics/`: 채용공고가 아닌 구현·설계·트러블슈팅 기반 블로그 글감
4. 파생 문서는 `## Parent`에서 branch-note로 upward link하고, branch-note의 `## Cluster / 묶음`에는 파생 문서 wikilink를 되돌려 적는다.
5. canonical 추출 요청이 없는 한 `wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`, `wiki/concepts/`, `wiki/projects/`를 바로 만들지 않는다.
파생 문서가 필요 없을 때도 그냥 생략하지 말고, branch-note의 cluster 섹션에 "없음" 또는 "추출할 별도 글감 없음"처럼 판단 결과를 남긴다.
## 강제 수준
이 문서는 에이전트와 개발자가 따라야 할 작업 규칙을 정의하지만, 그 자체로 빌드나 테스트를 실패시키는 자동 강제 장치는 아니다.
이 프로젝트의 규칙은 세 단계로 관리한다.
1. `AGENTS.md`: 에이전트와 개발자가 따라야 할 아키텍처, 작업 순서, 검증 원칙을 정의한다.
2. ArchUnit/Gradle/Test: 모듈 의존성, 계층 침범, 설정 바인딩, 동작 회귀를 자동으로 탐지한다.
3. Code Review/CI: 문서와 자동 검증이 놓친 설계 품질, 운영 위험, 템플릿 일관성을 최종 확인한다.
에이전트는 `AGENTS.md`를 따라 작업해야 하며, 현업 수준의 스켈레톤 완성도는 반드시 자동 검증과 리뷰로 보강해야 한다.
## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
`src/**/CLAUDE.md`를 함께 읽는다.
- `domain-core`: 순수 도메인 모델, 불변식, 이벤트, port. Spring/JPA/transport/IO 타입 금지.
- `application-core`: command, use case, application policy, transaction port, application 예외.
inbound DTO, persistence entity, adapter 타입 금지.
- `adapter:inbound:*`: HTTP/gRPC/GraphQL/WebSocket transport, DTO, validation, 인증·인가 매핑,
error mapping. repository 직접 호출과 비즈니스 정책 금지.
- `adapter:outbound:persistence-*`: JPA/PostgreSQL 또는 MongoDB persistence 구현과 mapping,
migration/vendor 동작. 유스케이스 정책 금지.
- 그 밖의 `adapter:outbound:*`: support, messaging, cache, notification, object storage,
file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는
registry만 따른다.
- `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지.
- `sample-portfolio`: 샘플/fixture consumer. production leaf가 의존하면 안 된다.
- `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지.
## 의존성 방향
family 수준 기본 방향:
```text
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
runtime modules -> shared-contract
sample-portfolio -> registered runtime leaves (fixture consumer only)
```
개별 edge는 `src/config/architecture/modules.json``allowed_dependencies`가 유일한 목록이다.
Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다.
## 기능 개발 프로토콜
새 기능이나 동작 변경은 다음 순서로 진행한다.
1. 요구사항을 읽고 소유 계층을 판단한다.
2. 구조나 동작이 바뀌면 `superpowers:brainstorming`으로 설계를 먼저 확정한다.
3. 여러 단계의 작업이면 `docs/superpowers/specs/`에 설계 문서를 남긴다.
4. 구현 전 `docs/superpowers/plans/`에 실행 계획을 작성한다.
5. 동작 변경은 테스트를 먼저 작성한다.
6. 새 비즈니스 개념은 `domain-core`에 먼저 둔다.
7. 유스케이스는 `application-core` command와 use case/service method로 표현한다.
8. 외부 연동은 registry가 가리키는 `adapter:outbound:*` leaf에서 port 구현으로 추가한다.
9. endpoint는 마지막에 해당 `adapter:inbound:*` leaf에서 얇게 연결한다.
10. focused test를 먼저 돌리고, 가능한 경우 전체 Gradle test를 돌린다.
11. LLM Wiki branch-note와 필요한 파생 raw 문서를 갱신한다.
12. 완료 응답에는 변경 파일, 검증 결과, Wiki capture 결과를 포함한다.
## 테스트 전략
계층별 테스트 기준:
- `domain-core`: Spring 없이 순수 unit test로 도메인 규칙을 검증한다.
- `application-core`: fake/in-memory port를 사용해 유스케이스 흐름을 검증한다.
- `adapter:inbound:*`: transport validation, auth mapping, status/response contract를 검증한다.
- `adapter:outbound:persistence-*`: persistence mapping, repository adapter, vendor/migration 동작을 검증한다.
- 그 밖의 `adapter:outbound:*`: external capability adapter contract를 검증한다.
- `app-bootstrap/settings`: configuration binding, validation, logging 설정을 검증한다.
검증 원칙:
- 완료를 주장하기 전에 `superpowers:verification-before-completion`을 사용한다.
- 실행한 명령과 결과를 최종 응답에 적는다.
- 테스트를 실행하지 못했다면 이유와 남은 위험을 솔직히 적는다.
권장 절차:
```bash
cd src
./gradlew <owner-gradle-path>:test --console=plain
./gradlew test
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
./gradlew verifyCleanArchitectureDependencies
./gradlew verifyPublicPathSnapshot
./gradlew verifyEnvKeys
```
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
## 설정과 런타임
설정 규칙:
- secrets를 코드, 테스트 fixture, 문서 예시에 하드코딩하지 않는다.
- 새 설정 그룹은 typed settings class로 만든다.
- 흩어진 `@Value`보다 configuration properties와 settings class를 선호한다.
- `.env.local`은 로컬 오버라이드로 취급한다.
- `application.yml`은 환경별로 안전한 기본값과 명확한 placeholder만 담는다.
- 새 settings class를 만들면 binding/validation 테스트를 추가한다.
Docker/runtime 규칙:
- Dockerfile 변경 시 build context와 runtime env 요구사항을 함께 확인한다.
- container 안에서 필요한 profile, port, env var를 문서나 예시 설정에 반영한다.
- local-only 경로와 운영 경로를 섞지 않는다.
## 템플릿 재사용 체크리스트
이 저장소를 새 프로젝트 시작점으로 사용할 때:
1. `src/settings.gradle``rootProject.name`을 새 프로젝트명으로 바꾼다.
2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다.
3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다.
4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가하고, 예시 코드는 `sample-portfolio`에 격리한다.
5. 모듈 이름과 경계는 유지한다.
6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다.
7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다.
8. README와 운영 문서를 새 프로젝트 설명으로 갱신한다.
9. 전체 테스트를 실행한다.
```bash
cd src
./gradlew test
```
## 금지된 지름길
에이전트는 다음을 하지 않는다.
- `domain-core`에 Spring/JPA annotation 추가
- controller에서 repository 직접 호출
- application method가 web request DTO를 인자로 받게 만들기
- application이나 domain에서 JPA entity 반환
- mapper에 비즈니스 정책 넣기
- filter/config/settings class에 유스케이스 넣기
- 요청 범위 밖의 대규모 리팩터링
- 사용자 변경사항 되돌리기
- 명시적 요청 없는 destructive git command 실행
- 테스트 미실행 상태에서 "완료"라고 말하기
## 작업 보고 규칙
최종 응답에는 다음을 포함한다.
- 변경한 파일
- 핵심 변경 내용
- 실행한 검증 명령
- 실패하거나 실행하지 못한 검증
- LLM Wiki branch-note와 파생 문서 캡처 결과
- 남은 위험 또는 후속 작업
짧은 작업이라도 검증 여부는 생략하지 않는다.
+120
View File
@@ -0,0 +1,120 @@
# CLAUDE.md
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template.
## Prime Directive
Preserve architecture before optimizing for speed. The following eight HARD-STOP conditions are a
synchronized summary of the canonical local policy in `AGENTS.md`:
1. `domain-core` gains framework, transport, database, or cloud dependencies.
2. A controller directly uses a repository, Spring Data interface, or persistence entity.
3. An inbound DTO leaks into `application-core` or `domain-core`.
4. Business rules move into mappers, filters, configuration, settings, or controllers.
5. Project dependencies violate `src/config/architecture/modules.json` or the Gradle dependency
gate.
6. Completion is claimed without the relevant verification or a named reason it could not run.
7. A repository/corpus conclusion is made without evidence proportional to its scope and risk.
8. Non-trivial work closes without the required LLM Wiki capture or a reported capture block.
If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must be resynchronized.
## Gradle policy authorities
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
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.
Commit policy is `human-only`: agents do not stage, commit, amend, or push implementation changes.
## Proportional workflow
- Low risk: work in the owning leaf, follow its nearest guidance, and run the focused check.
- Medium risk: use the relevant Superpowers design, planning, TDD, debugging, and review workflows
in proportion to the affected boundaries.
- High risk: make architecture and behavior decisions explicit, use staged architecture/spec/quality
review, and run architecture-wide verification authorized by the task.
Risk comes from change surface and runtime, security, data, or public-contract impact, not file
count.
## Module families
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules.
| Family | Responsibility | Stable dependency direction |
| --- | --- | --- |
| `domain-core` | Pure domain model, invariants, events, ports | Java stdlib and registered value-only contracts |
| `application-core` | Commands, use cases, application policies, transaction ports | `domain-core`, `shared-contract` |
| `adapter:inbound:*` | HTTP, gRPC, GraphQL, WebSocket transport boundaries | application/domain/shared contracts |
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
| `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`, `allowed_dependencies`, and `runtime_memberships` from
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
## Layer workflow
For a full use case, work in this order:
```text
domain-core
-> application-core
-> adapter:outbound:* (or persistence/identifier)
-> adapter:inbound:*
-> app-bootstrap wiring
```
Layer-only work stays inside that registered leaf plus its tests. If a required fix crosses a layer
or writable scope, stop and request context rather than expanding silently.
## Testing
- `domain-core`: pure JUnit unit tests.
- `application-core`: use-case tests with hand-rolled fakes; no web or persistence context.
- inbound adapters: focused transport slice/contract tests.
- persistence adapters: mapping/port contract tests; use a real datastore only when vendor semantics
require it.
- other outbound adapters: port contract tests with fake external systems; no real network.
- identifier: pure deterministic unit tests.
- bootstrap/settings: binding, validation, wiring, and architecture tests.
From `src/`, read the owning leaf's `gradle_path` from
`config/architecture/modules.json` and run `./gradlew <gradle-path>:test --console=plain`.
Architecture-wide commands:
```bash
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain
```
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires
the controller's workflow authorization.
## Advisory and reporting
Use dependency-DAG/topological reasoning and 35 materially distinct alternatives when that many
exist; use fewer when the option set is smaller. Judgment findings include falsifiable assumptions
and counterarguments. Deterministic compile, dependency, secret, or failed-test findings use
Rule → Evidence → Fix without manufactured debate.
Citation verification is profile-based: none for `review-lite`, blocking citations for
`review-standard`, and all material citations for `audit-deep`/`regulated`. Durable reports are
triggered by high risk, at least 3 blocking findings, an architecture decision, explicit user request,
or the regulated profile. Otherwise a concise result is allowed.
## LLM Wiki capture
For non-trivial implementation or workflow changes, use the exact vault path and capture sequence in
`AGENTS.md`. If the controller explicitly excludes wiki writes for a dispatched task, report the
handoff instead of writing outside scope.
+133
View File
@@ -0,0 +1,133 @@
# ca-skeleton — Clean Architecture Spring Boot 템플릿
ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스만 교체하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 작업 기록 게시판)에 격리합니다.
이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다.
## 아키텍처 한눈에
의존은 항상 바깥에서 안으로 흐릅니다. adapter가 core의 port에 의존하고, core는 adapter를 알지 못합니다. 이 방향이 유지되는 한 도메인 규칙과 기술 선택(웹 프레임워크, DB, 메시징)을 서로 독립적으로 바꿀 수 있습니다.
```text
app-bootstrap -> adapter:inbound:* -> application-core -> domain-core
app-bootstrap -> adapter:outbound:* -> application-core -> domain-core
모든 런타임 모듈 -> shared-contract
sample-portfolio -> 등록된 런타임 리프 (fixture 소비자 전용)
```
family 수준의 책임은 다음과 같습니다.
| 모듈 family | 책임 |
| --- | --- |
| `domain-core` | 순수 도메인 모델·불변식·이벤트·port. 프레임워크·transport·DB·IO 타입 금지 |
| `application-core` | command·유스케이스·application 정책·트랜잭션 port. inbound DTO·persistence entity 금지 |
| `adapter:inbound:*` | HTTP·gRPC·GraphQL·WebSocket transport 경계, DTO·validation·인증·에러 매핑 |
| `adapter:outbound:persistence-*` | JPA/PostgreSQL·MongoDB 영속 구현과 매핑·migration |
| `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 |
| `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 |
| `app-bootstrap` | Spring Boot entrypoint와 composition root |
| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 |
정확한 19개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는
[src/config/architecture/modules.json](src/config/architecture/modules.json)이 SSOT입니다. focused
test는 해당 Gradle path에서 `./gradlew <gradle-path>:test --console=plain` 형태로 파생하며, root
문서나 기억에서 개별 leaf edge를 추론하지 않습니다.
## 퀵스타트
전제조건은 Temurin 21(루트 [.tool-versions](.tool-versions)에 고정)과 Docker Engine 또는 Docker Desktop입니다. Gradle은 저장소 wrapper를 씁니다. 첫 실행 진입점은 하나입니다.
```bash
cd src
./gradlew bootstrap
```
`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), sample 격리 검증, `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다.
```bash
curl -fsS http://localhost:8080/api/healthcheck
```
로컬 스택을 내릴 때는 저장소 루트에서 실행합니다.
```bash
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)의 "템플릿 재사용 체크리스트"에 있습니다.
1. [src/settings.gradle](src/settings.gradle)의 `rootProject.name`을 새 서비스 이름으로 바꿉니다.
2. 패키지 루트 `dev.caskeleton`을 조직·서비스 패키지로 바꿉니다. 소스뿐 아니라 빌드·설정 파일의 참조도 함께 바꿔야 `mainClass`·`group`이 어긋나 `bootstrap`이 깨지지 않습니다.
```bash
cd src
find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g'
```
애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다.
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
4. 모듈 이름과 경계는 그대로 유지합니다.
검증은 sample-on과 sample-off를 모두 통과시킵니다.
```bash
cd src
./gradlew test
./gradlew :app-bootstrap:sampleOffTest
```
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
## 아키텍처 규칙과 검증
애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 정본 로컬
정책 권위는 [AGENTS.md](AGENTS.md)이며, [CLAUDE.md](CLAUDE.md)는 동기화된 요약입니다.
- `domain-core`는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다.
- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다.
- inbound DTO는 `application-core`나 `domain-core`로 들어가지 않습니다.
- 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다.
- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다.
이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을,
`verifyCleanArchitectureDependencies` 게이트가 JSON registry의 허용 Gradle project edge를
검사합니다.
```bash
cd src
./gradlew verifyCleanArchitectureDependencies
```
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
## 더 알아보기
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
- 모듈 레지스트리(19개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json)
- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md)
- 빌드·릴리스 공급망 파이프라인은 현재 Mode B 복구 범위에 포함되지 않았다. 현재 저장소가
제공하는 canonical workflow는 품질·의존성 취약점·링크 검사이며, release/publish 자동화는 별도
설계와 권한 검토 후 추가한다.
- 모듈별 설계 결정: [domain-core](src/domain-core/README.md) · [application-core](src/application-core/README.md) · [adapter:inbound:web](src/adapter/inbound/web/README.md) · [adapter:outbound:persistence-jpa](src/adapter/outbound/persistence-jpa/README.md) · [shared-contract](src/shared-contract/README.md) · [app-bootstrap](src/app-bootstrap/README.md)
+61
View File
@@ -0,0 +1,61 @@
# =============================================================================
# feature-container-runtime-contract — dev environment override
#
# Merge with base:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
#
# Dev intent:
# - Relaxes read-only filesystem (writable, no tmpfs constraint) so developers
# can hot-swap files without image rebuilds.
# - Increases memory allowance for dev/debugging workloads.
# - Disables restart-unless-stopped so crash loops don't mask startup errors.
# - Enables JMX remote port (local only, never in prod).
# - Mounts a local source volume for fast iteration (optional; mount when IDE
# attaches to the running container).
# =============================================================================
services:
app:
# Relax read-only constraint for local development.
read_only: false
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs
# More memory for dev profiling / heap dumps.
mem_limit: 1g
memswap_limit: 1g
environment:
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
# Use a shorter drain timeout in dev so restarts are faster.
APP_SERVER_SHUTDOWN_TIMEOUT: "5s"
APP_SERVER_SHUTDOWN: "graceful"
# Remote JMX — bind to localhost only; NEVER expose in production.
JAVA_TOOL_OPTIONS: >-
-XX:MaxRAMPercentage=75
-XX:+UseContainerSupport
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/tmp/heap
-Dserver.tomcat.basedir=/tmp
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.rmi.port=9999
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false
-Djava.rmi.server.hostname=127.0.0.1
ports:
- "8080:8080"
- "9001:9001"
- "127.0.0.1:9999:9999" # JMX — localhost only
# No health check constraint in dev; let the app start at its own pace.
healthcheck:
disable: true
# Do not restart automatically so crash loops stay visible.
restart: "no"
# Optional: mount heap dump directory to host for dev analysis.
volumes:
- type: bind
source: ./tmp/heap-dumps
target: /var/tmp/heap
bind:
create_host_path: true
+94
View File
@@ -0,0 +1,94 @@
# =============================================================================
# feature-container-runtime-contract — local infrastructure override
#
# Merge with base (and optionally dev):
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
#
# Local intent:
# - 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.
# - 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:
app:
env_file:
- ./src/.env
# Wire the app to the local Postgres service on the internal network.
environment:
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
# fallback matches env-keys.yaml SSOT default (30s); base compose grace is 40s.
APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}"
APP_SERVER_SHUTDOWN: "graceful"
# Database connection — points to the local `db` service below.
# Override with your actual DB credentials in a local .env file.
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
depends_on:
db:
condition: service_healthy
healthcheck:
test:
- "CMD"
- "wget"
- "--no-verbose"
- "--tries=1"
- "--spider"
- "http://localhost:8080/api/healthcheck"
interval: 5s
timeout: 3s
start_period: 20s
retries: 12
networks:
- caskeleton-local
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
TZ: "UTC"
# Persist data between restarts; remove the volume to start fresh.
volumes:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
# 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:
test:
[
"CMD-SHELL",
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
caskeleton-local:
driver: bridge
volumes:
caskeleton-db-data:
driver: local
+99
View File
@@ -0,0 +1,99 @@
# =============================================================================
# feature-container-runtime-contract — base docker-compose service definition
#
# Usage:
# docker compose up # base only
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up # dev override
# docker compose -f docker-compose.yml -f docker-compose.local.yml up # local override
#
# Graceful-shutdown sync table (feature-container-runtime-contract D5):
# App shutdown timeout : 30s (APP_SERVER_SHUTDOWN_TIMEOUT — SSOT: docs/registries/
# env-keys.yaml, owner feature-env-driven-runtime-configuration)
# Container preStop : 5s (handled by the orchestrator / stop_grace_period offset)
# stop_grace_period : 40s (30s app drain + 5s preStop + 5s safety margin)
#
# Ownership: env-driven-config owns the app shutdown *value* (30s); this contract owns the
# *relationship* (stop_grace_period >= app timeout + preStop + margin). The grace is therefore
# sized UP to the env-keys SSOT value (30s) — you do not shrink the app drain window to fit an
# arbitrary grace. This resolves the earlier 20s/35s drift, which had assumed an app-timeout
# value this branch does not own (env-keys.yaml is the SSOT and sets 30s).
#
# Memory limit (D4):
# The container memory limit MUST be set for -XX:MaxRAMPercentage=75 to be meaningful.
# Default 512m suits a small service; adjust per deployment.
# =============================================================================
services:
app:
build:
context: src/
dockerfile: Dockerfile
args:
RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}"
BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}"
GIT_SHA: "${GIT_SHA:-0000000}"
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
ports:
- "${APP_SERVER_PORT:-8080}:8080"
- "9001:9001"
environment:
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
# Graceful shutdown — fallback matches the env-keys.yaml SSOT default (30s).
# Override via .env or docker compose --env-file if you need a different value.
APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}"
APP_SERVER_SHUTDOWN: "graceful"
# ---- Read-only root filesystem (D2) -------------------------------------
# The application must write only to the tmpfs mounts declared below.
# /tmp — Tomcat basedir (see -Dserver.tomcat.basedir=/tmp in Dockerfile).
# /var/tmp/heap — heap dump landing zone (see -XX:HeapDumpPath=/var/tmp/heap).
read_only: true
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
memswap_limit: 512m
# ---- Graceful shutdown (D5) ---------------------------------------------
# stop_grace_period = app drain (30s, env-keys SSOT) + preStop (5s) + margin (5s) = 40s.
stop_grace_period: 40s
# ---- Health check -------------------------------------------------------
# Targets the actuator readiness probe on the management port (9001).
# CROSS-FEATURE COUPLING: /actuator/health/readiness is implemented by the
# parallel runtime-health + actuator branches. This healthcheck will report
# UNHEALTHY in this worktree until those branches are merged.
healthcheck:
test:
- "CMD"
- "wget"
- "--no-verbose"
- "--tries=1"
- "--spider"
- "http://localhost:9001/actuator/health/readiness"
interval: 30s
timeout: 5s
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:
@@ -0,0 +1,81 @@
# Object Storage Batch A Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Claimed level: R0 application contract only
- Provider readiness advanced: no
## Implemented scope
- Characterized the legacy caller-key overwrite, whole-object materialization, locator exposure,
eager filesystem directory creation, optional S3 bucket provisioning, and Poster transaction/API
coupling without changing those behaviors.
- Added provider-neutral identities, opaque checked references/handles, bounded streaming
callbacks, content identity, digest/range values, requests, receipts, outcomes, and narrow ports
under `dev.caskeleton.application.objectstorage`.
- Required an `ObjectOperationKey` on mutation requests and separated normal publication,
scan-maintenance, purge-maintenance, direct, and staged privilege surfaces.
- Added recursive contract-purity tests and an ArchUnit freeze for the one existing sample legacy
import.
- Marked the legacy `ObjectStoragePort` and `StoredObject` as removal boundaries without adapting
new semantic calls back to raw keys.
No provider-neutral kernel, canonical namespace/control codec, local R1 provider, S3/MinIO
qualification, sample migration, or R2 readiness claim is included.
## TDD evidence
The planned RED checks failed only for the intentionally missing types or removal annotations:
- `ObjectStorageIdentityContractTest`: missing identity types before Task 2 implementation.
- `ObjectContentContractTest` and `ObjectStorageValueContractTest`: missing content/value types
before Task 3 implementation.
- `ObjectStoragePortContractTest`: missing request/receipt/port family before Task 4 implementation.
- `ObjectStorageArchitectureContractTest`: missing legacy removal annotations before Task 5
implementation.
An initial ArchUnit DSL compilation error was a test-authoring error, not accepted as a RED result;
the rule was corrected and rerun.
## GREEN verification
All commands ran from `src/` and completed with `BUILD SUCCESSFUL`:
```bash
./gradlew :application-core:resolveAndLockAll --write-locks
./gradlew :application-core:verifyDependencyLocks --console=plain
./gradlew :application-core:test --tests '*ObjectStorageIdentityContractTest' --console=plain
./gradlew :application-core:test \
--tests '*ObjectContentContractTest' \
--tests '*ObjectStorageValueContractTest' --console=plain
./gradlew :application-core:test --tests '*ObjectStoragePortContractTest' --console=plain
./gradlew :application-core:test \
--tests '*ObjectStorageArchitectureContractTest' --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew :application-core:check --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain
```
The final combined legacy focused suites completed in 27 seconds. Deprecation-for-removal warnings
are expected evidence that legacy consumers remain visible; they are not suppressed.
## LLM Wiki capture
The canonical vault required by repository policy,
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
`raw/branch-notes/codex-objectstorage-production-capability.md` could not be created or updated.
No similarly named non-canonical clone was used. This exact access block is recorded in both the
plan and design headers and here at the Batch A boundary.
## Remaining gates and risks
- External broker and REST consumers and deployed legacy data were not inspected; Gate A remains
blocked for legacy removal or public API versioning.
- The new contracts have no provider implementation yet.
- The current legacy adapter retains whole-object and raw-locator behavior by design until the
later migration batch.
- No readiness registry row is promoted by this checkpoint.
@@ -0,0 +1,109 @@
# Object Storage Batch B Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Evidence grade: repository-local non-skipping unit/contract/application-context tests
- Advanced cards: local managed single upload R1, local managed download R1
- R2 or production-provider readiness advanced: no
## Implemented scope
- Added deterministic data/control namespaces, opaque reference/handle codecs, canonical request
fingerprints, frozen binding/policy revisions, and bounded operation epochs.
- Added six strict canonical JSON control-record families with fixed field order, outer SHA-256
envelopes, schema/size checks, corruption rejection, and checked-in golden digests.
- Added provider-neutral publication, scan, reference, direct-session, multipart, and pending-effect
state transitions with same-operation replay and conflicting-intent rejection.
- Added a provider contract and `filesystem-local-dev` implementation with bounded streaming,
immutable exclusive create, SHA-256 verification, exact inspect/version, full/range transfer,
conditional retirement, create resolution, restrictive permissions, and path/symlink
confinement.
- Added single-process exact-version control CAS and restart/corruption/fault characterization.
Logical control keys use `.record` physical leaves locally so object-store-valid prefix/leaf key
pairs cannot collide as filesystem file/directory paths.
- Added constructor-bound `app.object-storage` settings and compile-before-construction
provider/destination/route/policy binding. The capability is disabled by default and
`filesystem-local-dev` is rejected for `prod`/`production`.
- Added disabled, unselected, invalid, selected-success, selected-construction-failure, close,
legacy-only, and namespace-separated dual-run composition tests.
- Added semantic routing evidence for publish, replay without producer invocation, inspect,
full transfer, absent reference, and exact retained route lookup.
- Added the exact nine-card readiness registry. Only local managed single upload/download are R1;
direct, multipart, quarantine, retention, and production reconciliation remain R0.
## TDD and defect evidence
Planned RED checks failed for the intentionally absent codec/kernel/provider/settings/readiness
types before each implementation. Additional tests found and drove these corrections:
- Local control keys may legally have both a leaf and a child in object storage, while a filesystem
cannot have both `reference` and `reference/lifecycle`; local physical `.record` mapping fixed the
collision without changing logical keys.
- `ObjectInspectionPort.inspect` initially threw for an absent known-route reference; it now
returns `Optional.empty()` while incomplete/corrupt evidence still fails closed.
- The application purity test initially scanned its own test output after a full `check`; it now
derives the production class root from a production contract type.
- The general B7 ArchUnit rule initially classified objectstorage provider-internal SPI/control
return values as public adapter responses. The existing negative fixture remains active, while a
dedicated non-empty rule now checks the actual objectstorage `*Adapter` semantic boundaries.
No skipped Docker or external-service test is used as Batch B readiness evidence.
## GREEN verification
All commands ran from `src/` unless noted and completed with `BUILD SUCCESSFUL` after the documented
RED/fix cycles:
```bash
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectNamespaceCodecTest' \
--tests '*ObjectRequestFingerprintCodecTest' \
--tests '*ObjectOperationEpochTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectControlRecordCodecTest' \
--tests '*ObjectOperationStateMachineTest' \
--tests '*ObjectOperationKernelTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageProviderContract' \
--tests '*LocalDevObjectStorageProviderTest' \
--tests '*LocalDevObjectStorageRecoveryTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageBindingCompilerTest' \
--tests '*ObjectStorageCapabilityConfigTest' \
--tests '*RoutingObjectStorageAdapterTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
./gradlew :sample-portfolio:test --console=plain
./gradlew :application-core:check \
:adapter:outbound:objectstorage:check --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
```
The final combined application/objectstorage checkpoint completed in 23 seconds. The focused
Clean Architecture suite and dependency verification also passed.
## LLM Wiki capture
The canonical vault required by repository policy,
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
`raw/branch-notes/codex-objectstorage-production-capability.md` and any derived raw documents could
not be created or updated. No similarly named non-canonical clone was used. This exact access block
is recorded in the plan/design status and at this Batch B boundary.
## Remaining gates and risks
- `filesystem-local-dev` has no multi-node linearizability or power-loss durability evidence and is
forbidden in production profiles.
- The canonical S3/MinIO provider contribution, async bounded transport, provider qualification,
response-loss fault tests, and protected AWS evidence are not implemented.
- Direct grants, multipart, quarantine/scan, retention/legal hold, privileged purge, reapers, and
production reconciliation remain R0.
- The sample Poster workflow still uses the deprecated whole-`byte[]` port and transaction-coupled
legacy choreography. It is explicitly activated only in sample local/test configuration.
- External API/broker consumers and deployed legacy data remain uninspected, so Gate A still blocks
destructive migration or legacy removal.
@@ -0,0 +1,84 @@
# Object Storage Batch C Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Evidence grade: repository-local tests plus digest-pinned single-node MinIO/Toxiproxy tests
- AWS execution: not authorized; source set compiled only
- Production-provider readiness advanced: no
## Implemented scope
- Added exact AWS S3 and MinIO provider bindings, bounded evidence descriptors, qualifier/error
mapping, secret references, endpoint/owner/addressing validation, and selected-only lifecycle
construction.
- Added bounded async request/response bridges and the managed S3 put, inspect, full/range download,
checksum, exact-version, cancellation, and content-length paths.
- Added canonical conditional S3 control storage and operation response-loss resolution. Provider
ETags remain adapter-private and are never exposed as logical versions.
- Added low-level managed multipart planning, sharded immutable part ledgers, initiate-before-I/O
state, explicit create/upload/list/complete/abort calls, and exact completion verification.
- Added non-skipping MinIO contract/fault lanes, an AWS compile-only qualification lane, a protected
workflow, and gate-matrix coverage.
The exact MinIO image is
`minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8`.
The exact Toxiproxy image is
`ghcr.io/shopify/toxiproxy@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e`.
## Exact MinIO finding
Real-provider tests proved an asymmetric conditional profile:
- `PutObject If-None-Match: *` was accepted but overwrote an existing object.
- stale `PutObject If-Match` was rejected with HTTP 412.
- `CompleteMultipartUpload If-None-Match: *` was accepted and overwrote an existing object.
- checksum, HEAD, and range behavior passed the exercised contract.
Because immutable create and create-if-absent control CAS cannot be proven, the exact MinIO managed
and direct mutation profiles remain `UNSUPPORTED`. The implementation does not emulate missing
atomicity with HEAD followed by an unconditional write and does not promote a readiness card.
## TDD and verification
The task-focused RED runs first failed on the planned absent binding, bridge, conditional store,
multipart, and qualification types. Provider qualification then found the real MinIO conditional
behavior above; the descriptor and negative contract were changed instead of weakening the
contract.
Commands completed with `BUILD SUCCESSFUL`:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*S3ProviderBindingTest' \
--tests '*S3ProviderQualifierTest' \
--tests '*S3ProviderCompositionTest' --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
verifyCleanArchitectureDependencies --console=plain
bash ../.github/scripts/verify-gate-matrix.sh
```
The gate matrix reports 22 gates: 21 verified and the protected AWS qualification gate explicitly
`delegated-pending`.
## LLM Wiki capture
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
be created or updated. No similarly named non-canonical clone was used.
## Remaining risks
- No AWS request was executed, so there is no observed AWS provider claim.
- The pinned MinIO topology is a local single-node container and is not production TLS,
multi-node, durability, or linearizability evidence.
- The detailed managed multipart fault matrix is not exhaustive enough for R2.
- No sample migration, public API, scan/publication choreography, retention, purge, or reaper is
included in this checkpoint.
@@ -0,0 +1,104 @@
# Object Storage Batch D Checkpoint
- Date: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Scope: direct-transfer provider/application primitives only
- Public endpoint: none
- Readiness advanced: no; all direct cards remain R0
## Implemented scope
- Added direct single-upload session policy, durable prepared/issued transitions, bearer
redaction, exact completion verification, published-version download resolution, and an
S3-presigner lifecycle owned by the selected provider.
- Added direct multipart durable session and part-grant families, opaque acknowledgement tokens,
sharded part records, admission-close/expiry fencing, exact ledger validation, completion/abort
states, response-loss resolution, and persisted terminal exact-version replay.
- Added direct S3 initiate/discovery, exact-part presign, `ListParts` acknowledgement, conditional
complete followed by exact HEAD verification, and abort resolution.
- Registered direct single and multipart delegates only when their exact compiled capability is
selected. One presigner is constructed and closed exactly once.
- Added golden canonical envelopes for the direct session, direct multipart session, and direct
multipart grant families.
- Fixed `MultipartCompleteRequest` null validation so valid immutable `List.of(...)` input no longer
throws from `contains(null)`.
## Qualification truth
The exact MinIO release cannot prove create-only PUT or create-only multipart completion, so both
direct profiles are explicitly `UNSUPPORTED`. The direct MinIO contract/fault lanes are negative
admission tests: they prove no bearer or multipart mutation enters an unsupported profile. No test
skip is used as positive evidence.
The AWS managed/direct source sets compile, but no AWS call was made and no AWS evidence row was
published. No inbound controller, authorization surface, CORS runtime configuration, or public
direct API exists.
## Verification
Commands completed with `BUILD SUCCESSFUL`:
```bash
cd src
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectTransferCoordinatorTest' \
--tests '*PresignedGrantRedactionTest' \
--tests '*S3DirectTransferProviderTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectMultipartCoordinatorTest' \
--tests '*DirectMultipartRaceTest' \
--tests '*S3DirectMultipartProviderTest' \
--tests '*ObjectControlRecordCodecTest' \
--tests '*S3ProviderCompositionTest' --console=plain
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
--tests '*DirectTransfer*' --console=plain
./gradlew :adapter:outbound:objectstorage:test \
--tests '*DirectTransferCorsContractTest' --console=plain
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
./gradlew :adapter:outbound:objectstorage:check --console=plain
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
verifyCleanArchitectureDependencies --console=plain
./gradlew \
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
:adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
bash ../.github/scripts/verify-gate-matrix.sh
./gradlew test --console=plain
./gradlew check --console=plain
```
The module `check` includes unit tests, Checkstyle, Spotless, SpotBugs, architecture, configuration
processor, environment-key, and repository-wide policy checks. Existing test-only compiler
warnings remain non-failing. The final repository-wide test run completed 79 tasks and the final
repository-wide check completed 214 tasks.
## Deliberate limitations
- Issued bearer material is process-local. A restart fails closed instead of reconstructing or
reissuing an already-issued bearer.
- The signing clock/window is stored and bounded, but AWS SDK presigner query timing is not driven
by the injected application clock.
- The direct multipart recovery/race matrix covers its principal fences and completion response
loss but is not exhaustive enough for an R2 claim.
- Retention/Object Lock grant headers and a provider-enforced direct-single hard size ceiling are
not qualified.
- No public endpoint exists, so CORS evidence is a pure contract and no direct card may exceed R0
in the current registry.
## Approval Gate A
Tasks 2024 remain blocked until the user explicitly approves scanner ownership, the sample's
first publication profile, the additive asynchronous API/status contract, and digest transport.
No scan/publication/sample endpoint implementation was started.
## LLM Wiki capture
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
be created or updated. No similarly named non-canonical clone was used.
@@ -0,0 +1,94 @@
# Object Storage Batch E Pause Checkpoint
- Recorded: 2026-07-29 (Asia/Seoul)
- Branch: `codex/objectstorage-production-capability`
- Worktree:
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
- Status: implementation in progress; intentionally paused at the user's request
- Evidence grade: local unit/integration/architecture evidence only; no AWS R2 evidence
## Implemented at this checkpoint
- Staged integrity verification, fake-scanner routing, publication handoff fencing, and stable
replay receipts.
- Additive Poster V8 dual-read schema (renumbered from branch-local V7 during JPA integration),
upload/retirement intents, HMAC-sanitized idempotency scope,
PostgreSQL atomic claim SPI, and forward-only migration qualification lane.
- Short-transaction Poster image publication flow and additive locator-free `202` API under the
AIP-122-compatible `/posters/{id}/imagePublications` collection.
- Exact-reference/version logical retirement enqueue, lease/fence takeover, response-loss retry,
Poster deletion survival, and disabled-by-default worker composition.
- Isolated legacy migration contracts, report-only inspection, two-distinct-approver Ed25519
approval verification, nonce replay boundary, and explicit maintenance-only composition.
## Verification completed
The following focused command passed after the final architecture fixes:
```bash
cd src
./gradlew \
:sample-portfolio:spotlessApply \
:sample-portfolio:test --tests '*PosterImagePublicationControllerWireTest' \
:app-bootstrap:test --tests '*CleanArchitectureTest' \
--console=plain
```
The following focused suites also passed during this checkpoint:
```bash
./gradlew :adapter:outbound:objectstorage:test \
--tests '*LegacyObjectAdoptionServiceTest' \
--tests '*LegacyAdoptionApprovalVerifierTest' \
--tests '*ObjectStorageLegacyMigrationConfigTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*DeletePosterImageRetirementTest' \
--tests '*PosterImageRetirementCrashMatrixTest' \
--tests '*PosterImageRetirementConfigTest' \
--tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain
./gradlew :sample-portfolio:test \
--tests '*SampleApplicationContextTest' \
:sample-portfolio:posterImageMigrationTest --console=plain
```
The migration lane included
`PosterImageRetirementQualificationTest`, which proved that an exact retirement row survives
deletion of its Poster row.
## Failures found and resolved
- `spotlessJavaCheck` initially found formatting drift in newly changed application-core and
persistence files. The owner-module Spotless apply tasks fixed it.
- `SampleApplicationContextTest` initially failed because Spring's persistence exception advisor
could not CGLIB-proxy the final `PosterImageAttachmentCasRepository`. Removing `final` fixed the
context; the focused context suite then passed.
- `CleanArchitectureTest` initially rejected an application-core return type from the sample domain
and the kebab-case `image-publications` path. Conversion moved back to the application use case,
and the endpoint changed to the repository's AIP-122-compatible `imagePublications` segment. The
complete focused architecture suite then passed.
## Not yet re-run / not complete
- The combined Batch E checkpoint command stopped on the two architecture failures above before all
requested root tasks could complete. The focused failing suites passed after the fixes, but
`:sample-portfolio:check`, `verifyPublicPathSnapshot`, and the full combined Batch E command have
not been re-run after those final fixes.
- The complete repository `./gradlew test` and `./gradlew check` have not been re-run after the
Batch E additions.
- The legacy adoption runner/configuration is not yet wired to a production legacy inspector,
permission-checked trust-key loader, or durable control-record replay-store implementation.
- Tasks 2530 (Batch F) have not started in this continuation.
- Actual AWS qualification is blocked by Approval Gate B: no approved account, bucket/namespaces,
workload roles, signed deployment attestation, or mutation/test authority was supplied.
- No readiness card was promoted. Local/MinIO ceilings and unsupported conditional behavior remain
unchanged.
## Wiki capture
At this isolated-branch checkpoint, the then-selected private vault
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` was absent, so capture was blocked. The final
main integration was later captured in the user-designated public vault at
`raw/branch-notes/chore-main-worktree-capability-integration.md`, with the derived error note
`raw/errors/multi-worktree-contract-drift-2026-07-31.md`.
@@ -0,0 +1,73 @@
# Object Storage Phase 0 Inventory
- Captured: 2026-07-28
- Branch: `codex/objectstorage-production-capability`
- Scope: repository-local source, tests, configuration, migrations, and documentation
- Evidence grade: repository-local only; deployed data, broker subscribers, and external REST
consumers were not inspected
## Commands
```bash
rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \
src docs
rg -n 'image_key|posters/.*/image' src/sample-portfolio
rg -n 'poster\.image-attached|StoredObjectResponse|PosterResponse|imageKey' \
src/sample-portfolio docs
```
The commands completed successfully in the isolated worktree. Results are classified below.
Documentation hits in the Object Storage design/plan describe the migration and are not runtime
consumers. The `s3://bucket/key-1` fixture in
`IdempotencyStoreAdapterTest` belongs to the generic idempotency response-reference test and is not
an Object Storage legacy-port consumer.
## Repository-local runtime inventory
| Contract/data | Producer | Repository-local consumers | Classification |
| --- | --- | --- | --- |
| `ObjectStoragePort` / `StoredObject` | `application-core/application/storage` | filesystem and S3 adapters, `UploadPosterImageUseCase`, `PosterController`/`PosterWebMapper` | legacy runtime contract |
| `ca-skeleton.objectstorage.*` | `ObjectStorageSettings` / `ObjectStorageConfig` | sample runtime through its objectstorage runtime dependency | legacy runtime configuration |
| `file://` receipt | `FilesystemObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
| `s3://bucket/key` receipt | `S3ObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
| `/posters/{id}/image` | `PosterController` | repository tests and the generated/public HTTP contract | legacy inbound API |
| `StoredObjectResponse` | `PosterController` / `PosterWebMapper` | HTTP caller, with `key`, `size`, `contentType`, and `location` | legacy response DTO |
| `PosterResponse.imageKey` | `PosterWebMapper` | list/get/create/update/publish/archive HTTP responses | legacy general response field |
| `poster.image-attached` | `PosterEventPublisher` | no subscriber found in this repository | versionless broker event; external consumers unknown |
| `poster.image-attached.imageKey` | `PosterImageAttached` and publisher JSON | no subscriber found in this repository | raw locator-shaped event field |
| `poster.image_key` | Flyway V6, `PosterEntity`, persistence mapper | `Poster` aggregate and repository adapter | stored-data schema |
| `posters/{id}/image` key | `UploadPosterImageUseCase` | aggregate `imageKey`, event payload, DB row, HTTP response | deterministic overwriteable legacy key |
## Executable characterization
The following tests pin the current behavior without approving it as the target design:
- `LegacyObjectStorageBehaviorTest`
- caller-selected keys overwrite;
- `get` returns `Optional<byte[]>` and materializes the whole object;
- receipts expose `file://` and `s3://` locators.
- `LegacyObjectStorageConfigTest`
- missing backend configuration selects filesystem;
- context creation creates the filesystem directory before the first write;
- `autoCreateBucket=true` probes and creates a missing bucket during S3 bean construction.
- `LegacyPosterImageUploadCharacterizationTest`
- remote storage is called while `TransactionPort.inWrite` is active;
- the controller calls `MultipartFile.getBytes`;
- the response exposes raw key and provider location;
- Poster deletion leaves the legacy object untouched.
## External inventory gap and Gate A
Repository search does not prove that the following have no deployed consumers:
- `POST /posters/{id}/image`;
- `StoredObjectResponse.key` and `.location`;
- `PosterResponse.imageKey`;
- broker event type `poster.image-attached` and its `imageKey` payload;
- rows already stored in `poster.image_key`;
- filesystem/S3 objects already written under `posters/{id}/image`.
No deployed database, object namespace, access log, API client catalog, broker consumer group, schema
registry, or owning team approval was inspected. Therefore removal, in-place field rename, event
payload replacement, or legacy-object deletion remains blocked. Approval Gate A must obtain owner
and consumer evidence and choose an additive/versioned migration contract.
+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.
+120
View File
@@ -0,0 +1,120 @@
# Registry: Repository Access Capabilities
# SSOT: wiki/projects/ca-tmpl/registries/capabilities.yaml
# Schema owner: feature-contract-registry-governance
# Owner branch: feature-repository-access-permission-contract
# Last updated: 2026-06-05
#
# Notes
# - capability는 사용자 권한이 아니라 application use case가 infrastructure capability를
# 사용할 수 있는지에 대한 계약 (feature-repository-access-permission-contract).
# - enforcement default = ArchUnit annotation-based rule. compile-time annotation processor는
# alternative. runtime AOP는 forbidden.
# - capability 제거는 항상 breaking change. 추가는 additive (registry row 동반 시).
# - annotation 표기 (as-built, F1/F2 reconciled 2026-06-05): 코드 SSOT는 단일
# `@UseCaseCapability` (TYPE target, typed attribute). 노트 D2/D11의 flat
# `@UseCaseRepositoryAccess(Capability[])` 모델은 superseded. 7 capability ↔ as-built 매핑:
# READ_REPOSITORY/WRITE_REPOSITORY → repositoryAccess, TRANSACTION_REQUIRED → transactionMode,
# EXTERNAL_OUTBOUND_ALLOWED → externalOutboundAllowed, SENSITIVE_READ → sensitiveRead,
# BULK_WRITE → bulkWrite, CROSS_TENANT_ADMIN → crossTenantAdmin.
# 각 row의 annotation: 필드는 아래에서 as-built 표기로 정합됨.
capabilities:
# source: feature-repository-access-permission-contract — 판정 기준 "Required capability: READ_REPOSITORY"
# source: feature-application-port-usecase-contract — "read-only query use case는 readOnly 와 READ_REPOSITORY capability만 선언 가능"
- name: READ_REPOSITORY
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(repositoryAccess = READ_REPOSITORY)"
semantics: "use case가 read-only repository operation을 호출하는 것을 허용. query use case의 기본 capability. write/sensitive/bulk 작업은 별도 capability 선언이 없으면 forbidden."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:read-repository-capability
# source: feature-repository-access-permission-contract — 판정 기준 "Required capability: WRITE_REPOSITORY"
# source: feature-application-port-usecase-contract — "write use case는 transactionMode, idempotency, repositoryAccess를 명시해야 함"
- name: WRITE_REPOSITORY
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(repositoryAccess = WRITE_REPOSITORY)"
semantics: "use case가 mutating repository operation(insert/update/delete)을 호출하는 것을 허용. 단일/소량 write 기준이며 batch size > 100은 BULK_WRITE 별도 선언 필요. read-only use case에서 이 capability 없이 write repository 접근하면 fail."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:write-repository-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "SENSITIVE_READ marker = registry-managed metadata table (entity FQN + field name 단위)"
- name: SENSITIVE_READ
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(sensitiveRead = true)"
semantics: "PII/credential 등 sensitive field를 읽는 use case가 선언해야 하는 capability. marker는 registry-managed metadata table(entity FQN + field name 단위)에서 lookup. domain annotation 또는 JPA entity annotation 형태는 forbidden(domain에 framework 의존 회피). pseudonymized data read는 documented 시에만 예외 허용."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:sensitive-read-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "BULK_WRITE threshold = N > 100 또는 batch size > 100. 미만은 일반 WRITE_REPOSITORY로 충분"
- name: BULK_WRITE
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(bulkWrite = true)"
semantics: "단일 transaction 내 N > 100 또는 batch size > 100 mutating operation을 수행하는 use case가 선언해야 하는 capability. 이 미만이면 일반 WRITE_REPOSITORY로 충분. lock 점유 시간, pool 영향, retry 비용이 큰 작업을 명시화."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: WRITE_REPOSITORY
threshold: 100
compatibility_impact: breaking
required_test: architecture-enforcement:bulk-write-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "TRANSACTION_REQUIRED는 application-port branch의 TransactionPort contract와 연결되어야 하며 Spring @Transactional 직접 import로 충족하지 않음"
# source: feature-application-port-usecase-contract — TransactionPort Contract
- name: TRANSACTION_REQUIRED
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(transactionMode = WRITE | READ_ONLY | REQUIRES_NEW)"
semantics: "use case가 TransactionPort(또는 TransactionalUseCaseRunner)를 통해 transactional boundary를 갖는 것을 강제. Spring @Transactional의 application package 직접 import는 forbidden. infrastructure가 Spring transaction implementation을 제공하고 application은 port만 호출."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:transaction-required-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "EXTERNAL_OUTBOUND_ALLOWED 분류 = outbox row INSERT는 in-process(불요), polling publisher의 broker publish는 outbound(필요)"
# source: feature-application-port-usecase-contract — "outbound adapter 호출 use case에 EXTERNAL_OUTBOUND_ALLOWED가 없으면 실패"
- name: EXTERNAL_OUTBOUND_ALLOWED
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(externalOutboundAllowed = true)"
semantics: "use case가 외부 HTTP/message broker로 outbound 호출을 발생시키는 것을 허용. outbox claim 분류: outbox row INSERT는 in-process이므로 본 capability 불요. polling publisher의 broker publish는 outbound이므로 필요. domain event without transport detail은 outbound 호출이 아니므로 별도 분류."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:external-outbound-capability
# source: feature-repository-access-permission-contract — decisions 2026-05-22
# "CROSS_TENANT_ADMIN capability를 capability vocabulary에 추가 (tenant branch feature-tenant-context-policy와 cross-link)"
- name: CROSS_TENANT_ADMIN
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(crossTenantAdmin = true)"
semantics: "tenant 경계를 넘어 데이터에 접근/변경하는 admin use case가 선언해야 하는 capability. tenant-context-policy의 cross-tenant 정책과 cross-link되어야 하며, 단일 tenant 범위 use case에서 이 capability를 선언하면 review에서 reject. SENSITIVE_READ가 동반될 가능성이 높지만 자동 결합은 아님."
owner_branch: feature-repository-access-permission-contract
bound_to_capability: null
threshold: null
compatibility_impact: breaking
required_test: architecture-enforcement:cross-tenant-admin-capability
# Row count verification
# - feature-repository-access-permission-contract 판정 기준 "Required capability" 표에 명시된 7개:
# READ_REPOSITORY, WRITE_REPOSITORY, SENSITIVE_READ, BULK_WRITE, TRANSACTION_REQUIRED,
# EXTERNAL_OUTBOUND_ALLOWED, CROSS_TENANT_ADMIN.
# - source에 명시되지 않은 capability는 본 registry에 추가하지 않음 (추측 금지).
File diff suppressed because it is too large Load Diff
+918
View File
@@ -0,0 +1,918 @@
# Registry: Error Codes
# SSOT: wiki/projects/ca-tmpl/registries/error-codes.yaml
# Schema owner: feature-contract-registry-governance
# Category enum owner: feature-operational-error-observability-foundation
# Last updated: 2026-05-22
# Note: 이 파일은 Phase B 산출물. Phase C2(ca-tmpl 실 코드)에서 generated Java constants의 source.
#
# Schema (per row):
# code: UPPER_SNAKE_CASE
# category: VALIDATION | AUTH | AUTHZ | NOT_FOUND | CONFLICT |
# RATE_LIMIT | TRANSIENT_DEPENDENCY | PERMANENT_DEPENDENCY |
# DATA_INTEGRITY | INTERNAL
# http_status: int (async-only failures use 500 placeholder)
# retryable: bool
# retry_after_seconds: int | null (RATE_LIMIT/TRANSIENT 권고 backoff)
# owner_branch: source branch (raw/branch-notes/feature-*.md)
# owner_layer: presentation | application | domain | infrastructure | crosscut
# client_safe_message: no token / no principal raw / no internal path / no stack trace
# log_level: ERROR | WARN | INFO
# runbook_link: runbook://area/scenario OR null (client-error만 null 허용)
# compatibility_impact: none | additive | behavior-change | breaking
# required_test: owning contract test identifier
#
# Runbook policy (operational-runbook-contract L80):
# retryable=false + category ∈ {AUTH, AUTHZ, RATE_LIMIT, INTERNAL,
# TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY} ⇒ runbook_link 필수.
# VALIDATION/NOT_FOUND/CONFLICT/DATA_INTEGRITY는 client-error로 runbook 면제 가능.
# retryable=true 인 모든 row는 runbook_link 필수.
errors:
# ============================================================
# AUTH (feature-security-operational-baseline / Decision Matrix)
# ============================================================
# source: feature-security-operational-baseline L82 — "token 누락 | 401 | AUTH_TOKEN_MISSING | AUTH"
- code: AUTH_TOKEN_MISSING
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication required"
log_level: WARN
runbook_link: "runbook://auth/token-missing"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L83 — "token malformed (parse fail) | 401 | AUTH_TOKEN_MALFORMED | AUTH"
- code: AUTH_TOKEN_MALFORMED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: WARN
runbook_link: "runbook://auth/token-malformed"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L84 — "token expired (clock skew tolerance 60s 초과) | 401 | AUTH_TOKEN_EXPIRED | AUTH"
- code: AUTH_TOKEN_EXPIRED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication expired"
log_level: WARN
runbook_link: "runbook://auth/token-expired"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L85 — "invalid signature | 401 | AUTH_TOKEN_INVALID_SIGNATURE | AUTH"
- code: AUTH_TOKEN_INVALID_SIGNATURE
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/token-invalid-signature"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L86 — "issuer mismatch | 401 | AUTH_ISSUER_MISMATCH | AUTH"
- code: AUTH_ISSUER_MISMATCH
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/issuer-mismatch"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L87 — "audience mismatch | 401 | AUTH_AUDIENCE_MISMATCH | AUTH"
- code: AUTH_AUDIENCE_MISMATCH
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/audience-mismatch"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L88 — "unknown kid (JWKS 미캐시) | 401 + Retry-After 5s | AUTH_KID_UNKNOWN | AUTH"
- code: AUTH_KID_UNKNOWN
category: AUTH
http_status: 401
retryable: true # 2026-06-01: false→true. JWKS 키 회전 중 unknown kid 는 ~5s 후 JWKS refresh 로 해소 가능(transient). retry_after_seconds=5 + client_safe_message "please retry" 와 정합. 키 고정 정책으로 전환 시 false 복귀.
retry_after_seconds: 5
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed, please retry"
log_level: WARN
runbook_link: "runbook://auth/kid-unknown"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L89 — "JWKS endpoint outage ... | AUTH_JWKS_UNAVAILABLE | TRANSIENT_DEPENDENCY"
- code: AUTH_JWKS_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 30
owner_branch: feature-security-operational-baseline
owner_layer: infrastructure
client_safe_message: "Authentication service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://auth/jwks-unavailable"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-security-operational-baseline L90 — "claim mapping failure ... | 401 | AUTH_CLAIM_MAPPING_FAILED | AUTH"
- code: AUTH_CLAIM_MAPPING_FAILED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Authentication failed"
log_level: ERROR
runbook_link: "runbook://auth/claim-mapping-failed"
compatibility_impact: none
required_test: contract-verification:auth-category
# ============================================================
# AUTHZ (feature-security-operational-baseline)
# ============================================================
# source: feature-security-operational-baseline L91 — "valid token + 권한 부족 | 403 | AUTHZ_INSUFFICIENT_PERMISSION | AUTHZ"
- code: AUTHZ_INSUFFICIENT_PERMISSION
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Permission denied"
log_level: WARN
runbook_link: "runbook://authz/insufficient-permission"
compatibility_impact: none
required_test: contract-verification:authz-category
# source: feature-security-operational-baseline L92 — "valid token + tenant cross-access | 403 | AUTHZ_TENANT_MISMATCH | AUTHZ"
- code: AUTHZ_TENANT_MISMATCH
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: presentation
client_safe_message: "Permission denied"
log_level: ERROR
runbook_link: "runbook://authz/tenant-mismatch"
compatibility_impact: none
required_test: contract-verification:authz-category
# ============================================================
# INTERNAL (feature-security-operational-baseline + container-runtime)
# ============================================================
# source: feature-security-operational-baseline L93 — "public path misconfiguration ... | 500 + P1 alert | INTERNAL_AUTH_MISCONFIGURATION | INTERNAL"
- code: INTERNAL_AUTH_MISCONFIGURATION
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-security-operational-baseline
owner_layer: crosscut
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://auth/public-path-misconfiguration"
compatibility_impact: none
required_test: contract-verification:auth-category
# source: feature-container-runtime-contract L113 — "JVM OutOfMemoryError → ExitOnOutOfMemoryError로 137 exit, log에 error.code=JVM_OOM 명시"
- code: JVM_OOM
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-container-runtime-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://runtime/jvm-oom"
compatibility_impact: none
required_test: contract-verification:container-runtime-oom
# ============================================================
# DB / Persistence (feature-persistence-failure-baseline / SQLState Matrix)
# ============================================================
# source: feature-persistence-failure-baseline L85 — "08* | all | TRANSIENT_DEPENDENCY | DB_UNAVAILABLE | true"
- code: DB_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://db/unavailable"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L86 — "40001 | Postgres/MySQL | CONFLICT | DB_SERIALIZATION_FAILURE | true"
- code: DB_SERIALIZATION_FAILURE
category: CONFLICT
http_status: 409
retryable: true
retry_after_seconds: 1
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request conflicted with another transaction, please retry"
log_level: WARN
runbook_link: "runbook://db/serialization-failure"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L87 — "40P01 | Postgres | CONFLICT | DB_DEADLOCK | true (backoff)"
- code: DB_DEADLOCK
category: CONFLICT
http_status: 409
retryable: true
retry_after_seconds: 1
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request conflicted, please retry"
log_level: WARN
runbook_link: "runbook://db/deadlock"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L88 — "23502 | Postgres | DATA_INTEGRITY | DB_NULL_VIOLATION | false"
- code: DB_NULL_VIOLATION
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request violates a required field constraint"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L89 — "23503 | Postgres | DATA_INTEGRITY | DB_FK_VIOLATION | false"
- code: DB_FK_VIOLATION
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request references missing resource"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L90 — "23505 | Postgres | CONFLICT | DB_UNIQUE_VIOLATION | false (business mapping)"
- code: DB_UNIQUE_VIOLATION
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Resource already exists"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L91 — "23514 | Postgres | DATA_INTEGRITY | DB_CHECK_VIOLATION | false"
- code: DB_CHECK_VIOLATION
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request violates a value constraint"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L92 — "25P03 | Postgres | TRANSIENT_DEPENDENCY | DB_IDLE_IN_TX_TIMEOUT | true"
- code: DB_IDLE_IN_TX_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 2
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://db/idle-in-tx-timeout"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# source: feature-persistence-failure-baseline L93 — "57014 | Postgres | TRANSIENT_DEPENDENCY | DB_QUERY_CANCELED | false"
- code: DB_QUERY_CANCELED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: false
retry_after_seconds: null
owner_branch: feature-persistence-failure-baseline
owner_layer: infrastructure
client_safe_message: "Request was canceled, please retry later"
log_level: WARN
runbook_link: "runbook://db/query-canceled"
compatibility_impact: none
required_test: contract-verification:persistence-mapping
# ============================================================
# Rate limit / Idempotency (feature-rate-limit-idempotency-contract)
# ============================================================
# source: feature-rate-limit-idempotency-contract — rate limit response/log 기준 / Retry-After header 기준 (scope L29, L33)
- code: RATE_LIMIT_EXCEEDED
category: RATE_LIMIT
http_status: 429
retryable: true
retry_after_seconds: 1
owner_branch: feature-rate-limit-idempotency-contract
owner_layer: presentation
client_safe_message: "Too many requests, please retry after the indicated interval"
log_level: WARN
runbook_link: "runbook://rate-limit/exceeded"
compatibility_impact: none
required_test: contract-verification:rate-limit
# source: feature-rate-limit-idempotency-contract L71 — "200ms 초과 시 409 IDEMPOTENT_IN_FLIGHT (retryable=false, client는 polling)"
- code: IDEMPOTENT_IN_FLIGHT
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-rate-limit-idempotency-contract
owner_layer: application
client_safe_message: "A previous identical request is still being processed, please poll for result"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:idempotency
# source: feature-rate-limit-idempotency-contract L72 — "fingerprint mismatch (same key + different body) = 422 IDEMPOTENT_REQUEST_MISMATCH"
- code: IDEMPOTENT_REQUEST_MISMATCH
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-rate-limit-idempotency-contract
owner_layer: application
client_safe_message: "Idempotency key reused with different request body"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:idempotency
# ============================================================
# File / Resource (feature-file-resource-handling-contract)
# ============================================================
# source: feature-file-resource-handling-contract L69 — "spring.servlet.multipart.max-file-size 10MB ... Spring 단의 enforcement가 실패 시 envelope 응답 보장" / 테스트 계약 "oversized upload가 generic 500으로 처리되면 실패"
- code: UPLOAD_SIZE_EXCEEDED
category: VALIDATION
http_status: 413
retryable: false
retry_after_seconds: null
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Uploaded file exceeds maximum size"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:file-upload
# source: feature-file-resource-handling-contract L72 — "allowed content-type allowlist starting set ..."
- code: UPLOAD_CONTENT_TYPE_REJECTED
category: VALIDATION
http_status: 415
retryable: false
retry_after_seconds: null
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Uploaded content type is not allowed"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:file-upload
# source: feature-file-resource-handling-contract — Decisionized Work Items "path traversal | normalized storage key only ... | traversal test"
- code: PATH_TRAVERSAL_DETECTED
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Invalid file path"
log_level: ERROR
runbook_link: null
compatibility_impact: none
required_test: contract-verification:file-upload
# source: feature-file-resource-handling-contract L73 — "streaming download backpressure = response timeout 60s, max stream 100MB. 초과 시 truncate + ERROR log"
- code: DOWNLOAD_STREAMING_FAILURE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-file-resource-handling-contract
owner_layer: presentation
client_safe_message: "Download failed, please retry"
log_level: ERROR
runbook_link: "runbook://file/download-streaming-failure"
compatibility_impact: none
required_test: contract-verification:file-download
# ============================================================
# API contract transport-standard codes (feature-api-contract-baseline)
# ============================================================
# NOTE: feature-api-contract-baseline owns the transport-shape failure
# classification (D8 413/414, D9 406/415, D12 405, D15 412). These rows mirror
# dev.caskeleton.shared.error.OperationalError; the D11 status-mapping
# consistency test (owner: this branch, producer) fails the build when a code's
# registry http_status and the enum httpStatus() drift apart.
# source: feature-api-contract-baseline.md D12 — "405 Method Not Allowed + Allow header 의무"
- code: METHOD_NOT_ALLOWED
category: VALIDATION
http_status: 405
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "HTTP method not allowed for this resource"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D9 — "406 Not Acceptable = 응답 표현 협상 실패"
- code: NOT_ACCEPTABLE
category: VALIDATION
http_status: 406
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "No acceptable representation for the requested Accept header"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D15 — "If-Match mismatch 시 412 Precondition Failed"
- code: PRECONDITION_FAILED
category: CONFLICT
http_status: 412
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Resource was modified by another request; refetch and retry"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D8 — "request size limit 실패 분류 (413)"
- code: PAYLOAD_TOO_LARGE
category: VALIDATION
http_status: 413
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Request payload is too large"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D8 형제 — "URI 길이 실패 분류 (414)"
# NOTE: enforcement is Tomcat/gateway-owned (rejected before Spring dispatch);
# this row + code exist for status-mapping consistency. End-to-end 414 contract
# test is `planned` (gateway/Tomcat maxHttpHeaderSize 8KB boundary).
- code: URI_TOO_LONG
category: VALIDATION
http_status: 414
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Request URI is too long"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# source: feature-api-contract-baseline.md D9 — "415 Unsupported Media Type = 요청 본문 format 미지원"
- code: UNSUPPORTED_MEDIA_TYPE
category: VALIDATION
http_status: 415
retryable: false
retry_after_seconds: null
owner_branch: feature-api-contract-baseline
owner_layer: presentation
client_safe_message: "Request Content-Type is not supported"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:api-contract-status-mapping
# ============================================================
# Tenant (feature-tenant-context-policy)
# ============================================================
# source: feature-tenant-context-policy L71 — "tenant 미지원 모드에서 X-Tenant-Id 헤더 수신 시 400 TENANT_NOT_SUPPORTED (filter 단계)"
- code: TENANT_NOT_SUPPORTED
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-tenant-context-policy
owner_layer: presentation
client_safe_message: "Tenant context is not supported by this deployment"
log_level: WARN
runbook_link: null
compatibility_impact: none
required_test: contract-verification:tenant-policy
# ============================================================
# Validation / Business rule (feature-business-rule-validation-contract)
# ============================================================
# NOTE: business-rule-validation branch는 mapping 규칙 SSOT (syntax→VALIDATION,
# policy→AUTHZ/CONFLICT, invariant→CONFLICT/VALIDATION, persistence→PERSISTENCE/CONFLICT)
# 이며 구체 code는 example로 VALIDATION_EMAIL_FORMAT만 등장
# (feature-operational-error-observability-foundation L110). 실제 도메인별 code는
# Phase D(도메인 feature 적용) 시 본 registry에 추가.
# source: feature-operational-error-observability-foundation L110 — "code: VALIDATION_EMAIL_FORMAT, // registry-registered code" (validation field error JSON shape example)
- code: VALIDATION_EMAIL_FORMAT
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-operational-error-observability-foundation
owner_layer: presentation
client_safe_message: "Invalid email format"
log_level: INFO
runbook_link: null
compatibility_impact: none
required_test: contract-verification:validation-envelope
# ============================================================
# Cache (feature-cache-consistency-contract)
# ============================================================
# source: feature-cache-consistency-contract — Decisionized Work Items "Redis unavailable | degrade only if declared | fail-fast for required cache | generic INTERNAL | unavailable mapping" / 테스트 "Redis unavailable이 degrade 가능 여부 없이 INTERNAL로 처리되면 실패"
- code: CACHE_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 2
owner_branch: feature-cache-consistency-contract
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://cache/unavailable"
compatibility_impact: none
required_test: contract-verification:cache-consistency
# source: feature-cache-consistency-contract L70 — "stampede 방지 default = single-instance Caffeine local lock, multi-instance HPA 시 Redisson RLock distributed mutex" / 테스트 "동일 key에 대해 동시 cache miss 시 backend 호출이 1회로 제한되는지 verify (stampede). 미충족 시 실패"
- code: CACHE_STAMPEDE_LOCK_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 1
owner_branch: feature-cache-consistency-contract
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: WARN
runbook_link: "runbook://cache/stampede-lock-timeout"
compatibility_impact: none
required_test: contract-verification:cache-consistency
# ============================================================
# Outbound HTTP (feature-outbound-http-client-baseline)
# ============================================================
# source: feature-outbound-http-client-baseline L70 — "outbound HTTP timeout default = connect 2s / read 5s / global call 10s" + scope "timeout/connect/DNS failure 분류" / 테스트 "upstream timeout은 retryable dependency failure로 분류되어야 함"
- code: DEPENDENCY_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 504
retryable: true
retry_after_seconds: 2
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service did not respond in time, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/timeout"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류" + L70 connect=2s timeout
- code: DEPENDENCY_CONNECT_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 2
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service unreachable, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/connect-failed"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류"
- code: DEPENDENCY_DNS_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service unreachable, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/dns-failed"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류" / 테스트 "401/403은 credential/scope/config 문제로 분류되어야 함"
- code: DEPENDENCY_4XX_CLIENT
category: PERMANENT_DEPENDENCY
http_status: 502
retryable: false
retry_after_seconds: null
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service rejected the request"
log_level: ERROR
runbook_link: "runbook://dependency/4xx-client"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류"
- code: DEPENDENCY_5XX_SERVER
category: TRANSIENT_DEPENDENCY
http_status: 502
retryable: true
retry_after_seconds: 2
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service error, please retry"
log_level: ERROR
runbook_link: "runbook://dependency/5xx-server"
compatibility_impact: none
required_test: contract-verification:outbound-http
# source: feature-outbound-http-client-baseline L69 — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용" + Decisionized "circuit breaker | Resilience4j optional env"
- code: DEPENDENCY_CIRCUIT_OPEN
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 10
owner_branch: feature-outbound-http-client-baseline
owner_layer: infrastructure
client_safe_message: "Upstream service temporarily unavailable, please retry later"
log_level: WARN
runbook_link: "runbook://dependency/circuit-open"
compatibility_impact: none
required_test: contract-verification:outbound-http
# ============================================================
# Outbox (feature-domain-event-outbox-contract)
# ============================================================
# source: feature-domain-event-outbox-contract L67 — "outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD" + scope "publish 실패 분류" / 판정 "publish 실패가 retry/DLQ/log/runbook 기준 없이 삼켜지면 실패"
- code: OUTBOX_PUBLISH_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 500
retryable: true
retry_after_seconds: 30
owner_branch: feature-domain-event-outbox-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://outbox/publish-failed"
compatibility_impact: none
required_test: contract-verification:outbox-publish
# source: feature-domain-event-outbox-contract L67 — outbox status enum "DEAD" / Outbox Defaults "DLQ | background-job branch owner"
- code: OUTBOX_DEAD_LETTER
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-domain-event-outbox-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://outbox/dead-letter"
compatibility_impact: none
required_test: contract-verification:outbox-dlq
# ============================================================
# Background job / Async (feature-background-job-async-contract)
# ============================================================
# source: feature-background-job-async-contract — Decisionized "saturation | bounded executor + rejection log" / L72 "saturation policy default = AbortPolicy" / 테스트 "executor rejection이 structured log 없이 발생하면 실패"
- code: JOB_EXECUTOR_REJECTED
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-background-job-async-contract
owner_layer: infrastructure
client_safe_message: "Service temporarily unavailable"
log_level: ERROR
runbook_link: "runbook://job/executor-rejected"
compatibility_impact: none
required_test: contract-verification:async-saturation
# source: feature-background-job-async-contract L69 — "기본 backoff는 exponential backoff with jitter, max attempts 3, DLQ after exhausted attempts" + scope "shutdown 중 job 처리 기준" / L73 graceful shutdown ≤19s
- code: JOB_TIMEOUT
category: TRANSIENT_DEPENDENCY
http_status: 500
retryable: true
retry_after_seconds: 10
owner_branch: feature-background-job-async-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://job/timeout"
compatibility_impact: none
required_test: contract-verification:async-timeout
# source: feature-background-job-async-contract L69 — "DLQ after exhausted attempts" + Decisionized "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted | ... | infinite retry"
- code: JOB_DEAD_LETTER
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-background-job-async-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://job/dead-letter"
compatibility_impact: none
required_test: contract-verification:async-dlq
# ============================================================
# Distributed Lock (feature-distributed-lock-contract)
# ============================================================
# source: feature-distributed-lock-contract D7 — "lock 획득 실패/timeout 의 error code =
# LOCK_ACQUISITION_TIMEOUT (category CONFLICT, retryable true, client_safe true) + metric
# lock.acquisition" / D5 — "try-lock + 유한 waitTime + lease(TTL) 필수, 무한 blocking 금지".
# category CONFLICT 는 기존 enum 재사용; retryable=true — 락 보유자가 임계 구역을 빠져나오면
# 동일 요청 재시도로 해소된다(transient contention). DB_DEADLOCK / DB_SERIALIZATION_FAILURE 와
# 같은 retryable CONFLICT 계열(409). 본 코드는 distributedLockProvider 획득 timeout 전용이며
# cache stampede lock 의 CACHE_STAMPEDE_LOCK_TIMEOUT(cache-consistency, TRANSIENT_DEPENDENCY 503)
# 과 의미가 구분된다 — 후자는 캐시 백엔드 의존성 timeout, 전자는 분산 상호배제 contention.
- code: LOCK_ACQUISITION_TIMEOUT
category: CONFLICT
http_status: 409
retryable: true
retry_after_seconds: 1
owner_branch: feature-distributed-lock-contract
owner_layer: infrastructure
client_safe_message: "Resource is busy, please retry"
log_level: WARN
runbook_link: "runbook://lock/acquisition-timeout"
compatibility_impact: none
required_test: contract-verification:lock-acquisition-timeout
# ============================================================
# Migration / Startup (feature-migration-startup-contract)
# ============================================================
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... migration 실패=70 ..." + Decisionized "startup failure log | structured log with startup.phase, error.code, error.category"
- code: MIGRATION_FAILED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://migration/failed"
compatibility_impact: none
required_test: contract-verification:migration-startup
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = env 누락/malformed=78 ..." / 테스트 "required env 누락 시 startup이 성공하면 실패"
- code: STARTUP_VALIDATION_FAILED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://startup/validation-failed"
compatibility_impact: none
required_test: contract-verification:migration-startup
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... required adapter disabled=72" / 테스트 "disabled required adapter로 app이 뜨면 실패"
- code: REQUIRED_ADAPTER_DISABLED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://startup/required-adapter-disabled"
compatibility_impact: none
required_test: contract-verification:migration-startup
# source: feature-integration-adapter-templates §구현 가이드 §4 (Layer 3) + §Audit A2.
# Runtime-lifecycle fail-fast for an invoke against a DISABLED optional adapter
# (Kafka/Redis/Slack/Google Email). Deliberately distinct from the startup-lifecycle
# REQUIRED_ADAPTER_DISABLED above (exit 72): a runtime invoke ≠ a startup validation,
# so reusing the startup code would conflate two lifecycles (A2 resolution — new
# runtime code owned by this branch). retryable=false: the adapter stays disabled
# until redeploy, so retrying the same call never clears it.
- code: ADAPTER_DISABLED
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-integration-adapter-templates
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://adapter/adapter-disabled"
compatibility_impact: none
required_test: adapter-contract:adapter-disabled-runtime-call
# source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... profile mismatch=71" / 테스트 "prod profile에서 local-only 설정이 켜지면 실패"
- code: PROFILE_MISMATCH
category: INTERNAL
http_status: 500
retryable: false
retry_after_seconds: null
owner_branch: feature-migration-startup-contract
owner_layer: infrastructure
client_safe_message: "Internal server error"
log_level: ERROR
runbook_link: "runbook://startup/profile-mismatch"
compatibility_impact: none
required_test: contract-verification:migration-startup
# ============================================================
# Management / Actuator (feature-management-actuator-security-contract)
# ============================================================
# source: feature-management-actuator-security-contract — Exposure Policy "env/configprops | forbidden" "heapdump/threaddump | forbidden unless break-glass runbook" "shutdown | forbidden" / 테스트 "prod에서 env/configprops endpoint가 노출되면 실패"
- code: ACTUATOR_FORBIDDEN
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-management-actuator-security-contract
owner_layer: presentation
client_safe_message: "Permission denied"
log_level: WARN
runbook_link: "runbook://management/actuator-forbidden"
compatibility_impact: none
required_test: contract-verification:management-actuator
+220
View File
@@ -0,0 +1,220 @@
# Registry: HTTP Headers
# SSOT: wiki/projects/ca-tmpl/registries/headers.yaml
# Schema owner: feature-contract-registry-governance
# Last updated: 2026-05-22
#
# Conventions:
# - HTTP header name: kebab-case (X-Request-Id, X-Tenant-Id)
# - W3C standard headers: lowercase (traceparent, tracestate)
# - mdc_key: snake_case (foundation SSOT)
# - envelope_meta_field: camelCase (envelope SSOT)
headers:
# source: feature-operational-error-observability-foundation.md L97
# "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id"
- name: X-Request-Id
direction: both
type: ulid
required: false
generated_if_missing: true
mdc_key: request_id
envelope_meta_field: requestId
owner_branch: feature-operational-error-observability-foundation
case_style: kebab
compatibility_impact: none
required_test: contract-verification:envelope-headers
# source: feature-api-contract-baseline.md L67
# "X-Api-Version은 실험/compatibility 보조 header이며 path version과 충돌하면 path가 우선"
- name: X-Api-Version
direction: inbound
type: string
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-api-contract-baseline
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:api-versioning
# source: feature-api-contract-baseline.md L77 / feature-rate-limit-idempotency-contract.md L66-67
# "idempotency header 이름은 Idempotency-Key" / "기본 scope는 (authenticatedPrincipal, idempotencyKey, useCaseName)"
- name: Idempotency-Key
direction: inbound
type: string
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:idempotency-replay
# source: feature-rate-limit-idempotency-contract.md L85 / foundation L85
# "RATE_LIMIT | ... | 429 | true (Retry-After 이후)" / "retry-after 기준 없이 429를 반환하면 실패"
- name: Retry-After
direction: outbound
type: duration-seconds
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
# rate-limit 응답 표면 (limit/remaining/reset 3종은 표준 rate-limit signaling)
- name: X-RateLimit-Limit
direction: outbound
type: numeric
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
- name: X-RateLimit-Remaining
direction: outbound
type: numeric
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준)
- name: X-RateLimit-Reset
direction: outbound
type: rfc3339-date
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-rate-limit-idempotency-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:rate-limit-headers
# source: feature-api-compatibility-deprecation-contract.md L87
# "deprecation marker | OpenAPI deprecated: true + branch note | response header optional"
- name: Deprecation
direction: outbound
type: rfc3339-date
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-api-compatibility-deprecation-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:deprecation-marker
# source: feature-api-compatibility-deprecation-contract.md L87
# "deprecation marker | OpenAPI deprecated: true + branch note | response header optional" (RFC 8594 Sunset)
- name: Sunset
direction: outbound
type: rfc3339-date
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-api-compatibility-deprecation-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:deprecation-marker
# source: feature-distributed-tracing-contract.md L64, L85
# "propagation header는 W3C traceparent default" / "HTTP | traceparent, tracestate (W3C)"
- name: traceparent
direction: both
type: string
required: false
generated_if_missing: true
mdc_key: trace_id
envelope_meta_field: traceId
owner_branch: feature-distributed-tracing-contract
case_style: kebab
compatibility_impact: none
required_test: contract-verification:trace-propagation
# source: feature-distributed-tracing-contract.md L66, L85
# "propagation format = W3C traceparent + tracestate only. B3 propagation은 forbidden"
- name: tracestate
direction: both
type: comma-separated
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-distributed-tracing-contract
case_style: kebab
compatibility_impact: none
required_test: contract-verification:trace-propagation
# source: feature-operational-error-observability-foundation.md L100
# "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id"
- name: X-Correlation-Id
direction: both
type: ulid
required: false
generated_if_missing: true
mdc_key: correlation_id
envelope_meta_field: correlationId
owner_branch: feature-operational-error-observability-foundation
case_style: kebab
compatibility_impact: none
required_test: contract-verification:envelope-headers
# source: feature-tenant-context-policy.md L69, L101 (foundation)
# "tenant resolution 우선순위 = ... (2) 명시적 X-Tenant-Id 헤더 (admin/internal API only)" /
# "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)"
- name: X-Tenant-Id
direction: both
type: ulid
required: false
generated_if_missing: false
mdc_key: tenant_id
envelope_meta_field: null
owner_branch: feature-tenant-context-policy
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:tenant-header-policy
# source: feature-security-operational-baseline.md L66
# "JWT Resource Server를 baseline security model로 둠" (Bearer token via Authorization header)
- name: Authorization
direction: inbound
type: bearer-token
required: false
generated_if_missing: false
mdc_key: null
envelope_meta_field: null
owner_branch: feature-security-operational-baseline
case_style: kebab
compatibility_impact: none
required_test: contract-verification:jwt-resource-server
# source: feature-security-operational-baseline.md L83-90 (AuthN/AuthZ Decision Matrix)
# 401 응답 시 WWW-Authenticate (Bearer realm/error) — Spring Security JWT Resource Server 표준 challenge header
- name: WWW-Authenticate
direction: outbound
type: string
required: false
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-security-operational-baseline
case_style: kebab
compatibility_impact: none
required_test: contract-verification:jwt-resource-server
+294
View File
@@ -0,0 +1,294 @@
# Registry: MDC / Log Keys
# SSOT: wiki/projects/ca-tmpl/registries/mdc-keys.yaml
# Schema owner: feature-contract-registry-governance
# MDC SSOT: feature-operational-error-observability-foundation
# Last updated: 2026-05-22
#
# Conventions:
# - MDC key naming: snake_case (foundation L93 "snake_case 강제. camelCase / dot.case 금지.")
# - cardinality_safe_for_metric=true 인 key만 metric tag로 사용 가능
# - foundation L93-102 표 "MDC Key Standard (final)" 6개가 core SSOT
mdc_keys:
# source: feature-operational-error-observability-foundation.md L97
# "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id"
- key: request_id
type: ulid
source: inbound_filter
required_in: [request, dependency, security, application]
http_header_mapping: X-Request-Id
envelope_field: meta.requestId
propagation: [http, async, message]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L98
# "trace_id | Micrometer Tracing | W3C traceparent header"
- key: trace_id
type: string
source: observation_context
required_in: [request, dependency, application]
http_header_mapping: traceparent
envelope_field: meta.traceId
propagation: [http, async, message]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L99
# "span_id | Micrometer Tracing | W3C traceparent"
# NOTE: background-job-async-contract L71 "span_id는 Micrometer Observation context에서 자동 전파(MDC explicit copy 불필요)"
- key: span_id
type: string
source: observation_context
required_in: [request, dependency]
http_header_mapping: traceparent
envelope_field: null
propagation: [http, async]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L100
# "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id"
- key: correlation_id
type: ulid
source: inbound_filter
required_in: [request, dependency, application]
http_header_mapping: X-Correlation-Id
envelope_field: meta.correlationId
propagation: [http, async, message]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# source: feature-operational-error-observability-foundation.md L101 + feature-tenant-context-policy.md L70
# "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)" /
# "tenant ID format = opaque ULID (26 chars Crockford base32)"
# NOTE: tenant L73 "tenant_id ULID 원본은 metric tag에 직접 사용 금지"
- key: tenant_id
type: ulid
source: security_context
required_in: [request, dependency, security, audit]
http_header_mapping: X-Tenant-Id
envelope_field: null
propagation: [http, async, message]
owner_branch: feature-tenant-context-policy
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: additive
required_test: contract-verification:tenant-leakage
# source: feature-operational-error-observability-foundation.md L102
# "user_principal | security context (pseudonymized only) | log only, headers forbidden"
- key: user_principal
type: string
source: security_context
required_in: [security, audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-operational-error-observability-foundation
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-mdc-keys
# ── log type extensions (log-management-contract L101-109 "Log Type별 필수 필드") ──
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
# NOTE: application-port-usecase-contract / business 측 operation 식별자 (uri_template과 별도 application-set)
- key: operation
type: string
source: application_set
required_in: [application, dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
- key: method
type: string
source: inbound_filter
required_in: [request]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms"
# NOTE: metrics L86 "status_code | 7 (1xx-5xx + ok/other)" — bounded
- key: status
type: numeric
source: inbound_filter
required_in: [request]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L105-106 "request | ... duration_ms" / "dependency | ... duration_ms"
- key: duration_ms
type: numeric
source: application_set
required_in: [request, dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, duration_ms, outcome, error_code"
# NOTE: metrics L88 "dependency_name | 50" — bounded
- key: dependency_name
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, ..."
- key: dependency_type
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 + metrics L91 "outcome (resilience4j) | 5 (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED)"
- key: outcome
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L106 "dependency | ... error_code (실패 시)"
# NOTE: metrics L89 "error_code | 100 — error registry row 상한과 정합" — bounded
- key: error_code
type: string
source: application_set
required_in: [dependency]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L107
# "security | event_type, user_principal (pseudonymized), source_ip (anonymized — last octet zeroed)"
- key: event_type
type: string
source: application_set
required_in: [security, audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L107 "security | ... source_ip (anonymized — last octet zeroed)"
# NOTE: metrics L93 "high-cardinality 금지 tag: ... ip_address"
- key: source_ip_anon
type: string
source: inbound_filter
required_in: [security]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L108 "audit | actor, action, target, before_hash, after_hash, occurred_at"
- key: actor
type: string
source: security_context
required_in: [audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L108 "audit | actor, action, target, ..."
- key: action
type: string
source: application_set
required_in: [audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: true
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
# source: feature-log-management-contract.md L108 "audit | actor, action, target, ..."
- key: target
type: string
source: application_set
required_in: [audit]
http_header_mapping: null
envelope_field: null
propagation: [none]
owner_branch: feature-log-management-contract
cardinality_safe_for_metric: false
case_style: snake
compatibility_impact: none
required_test: contract-verification:log-fields
+845
View File
@@ -0,0 +1,845 @@
# Registry: Metrics
# SSOT: wiki/projects/ca-tmpl/registries/metrics.yaml
# Schema owner: feature-contract-registry-governance
# Owner branch: feature-metrics-alerting-contract
# Last updated: 2026-05-22
#
# Notes
# - Naming: Micrometer dot.case + unit suffix (.seconds | .bytes | .total).
# - Tag cardinality bounds are SSOT of feature-metrics-alerting-contract "Cardinality Bounds" table.
# - High-cardinality tags forbidden globally: user_id, request_id, raw_url, raw_query,
# raw_header_value, ip_address. These MUST NOT appear in any row.
# - tenant_id label is bounded mapping table id OR cohort bucket only (ULID raw forbidden).
# - error_code tag cardinality_limit follows error-codes.yaml row count (max 100).
metrics:
# === HTTP server (inbound) ===
# source: feature-metrics-alerting-contract — Metric/Alert Defaults
# "HTTP metric | http.server.requests with method/status/uri-template | raw URL or user id tag"
- name: http.server.requests
type: timer
unit: seconds
tags:
- name: method
cardinality_limit: 8
allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER]
- name: status
cardinality_limit: 7
allowed_values: [1xx, 2xx, 3xx, 4xx, 5xx, ok, other]
- name: uri_template
cardinality_limit: 200
validation: must_be_template_not_raw_uri
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "error_rate > 5% for 5m OR > 10% for 1m"
p2: "error_rate > 1% for 10m"
p3: "error_rate > 0.1% for 1h"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [method, status, uri_template]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — P1/P2/P3 정량 기준 (HTTP latency p99)
# "P1: p99 > 5s 5분 / P2: p99 > 1s 10분 / P3: p99 > 500ms 30분"
- name: http.server.requests.latency
type: timer
unit: seconds
tags:
- name: method
cardinality_limit: 8
allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER]
- name: uri_template
cardinality_limit: 200
validation: must_be_template_not_raw_uri
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "p99 > 5s for 5m"
p2: "p99 > 1s for 10m"
p3: "p99 > 500ms for 30m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [method, uri_template, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === HTTP client (outbound dependency) ===
# source: feature-metrics-alerting-contract — Metric/Alert Defaults
# "dependency metric | dependency.client.requests with dependency.name/type/outcome | endpoint with secret tag"
# source: feature-outbound-http-client-baseline — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용"
- name: dependency.client.requests
type: timer
unit: seconds
tags:
- name: dependency_name
cardinality_limit: 50
- name: dependency_type
cardinality_limit: 10
allowed_values: [http, grpc, db, cache, queue, broker, other]
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "required dep unavailable for 2m"
p2: "optional dep degraded for 5m"
p3: "spike alert (10x baseline)"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [dependency_name, dependency_type, outcome, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-outbound-http-client-baseline — decisions
# "retry/circuit breaker 기본 라이브러리는 Resilience4j"
# source: feature-metrics-alerting-contract — "retry/CB minimum: resilience4j.retry.calls{outcome}"
- name: resilience4j.retry.calls
type: counter
unit: total
tags:
- name: name
cardinality_limit: 50
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "retry exhaustion rate > 1% for 10m"
owner_branch: feature-outbound-http-client-baseline
log_field_mapping: [dependency_name, outcome, retry_attempt]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.state"
- name: resilience4j.circuitbreaker.state
type: gauge
unit: total
tags:
- name: name
cardinality_limit: 50
- name: state
cardinality_limit: 6
allowed_values: [CLOSED, OPEN, HALF_OPEN, DISABLED, FORCED_OPEN, METRICS_ONLY]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "state == OPEN for required dependency for 2m"
p2: "state == OPEN for optional dependency for 5m"
owner_branch: feature-outbound-http-client-baseline
log_field_mapping: [dependency_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.calls{outcome}"
- name: resilience4j.circuitbreaker.calls
type: timer
unit: seconds
tags:
- name: name
cardinality_limit: 50
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "CIRCUIT_OPEN rate > 1% for 10m"
owner_branch: feature-outbound-http-client-baseline
log_field_mapping: [dependency_name, outcome, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === DB connection pool ===
# source: feature-persistence-failure-baseline — Hikari Alert Threshold
# "pool wait p99 > 100ms 5분 지속 → P2 / pool exhaustion (active = max) > 1분 → P1"
# source: feature-metrics-alerting-contract — "hikaricp.connections.acquire{outcome='timeout'} p99 > 100ms"
- name: hikaricp.connections.acquire
type: timer
unit: seconds
tags:
- name: pool
cardinality_limit: 5
- name: outcome
cardinality_limit: 3
allowed_values: [SUCCESS, TIMEOUT, FAILURE]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "pool exhaustion (active == max) for 1m"
p2: "acquire p99 > 100ms for 5m"
owner_branch: feature-persistence-failure-baseline
log_field_mapping: [pool, outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준" + Hikari Alert Threshold
- name: hikaricp.connections.usage
type: timer
unit: seconds
tags:
- name: pool
cardinality_limit: 5
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "usage p99 elevated > 10m"
owner_branch: feature-persistence-failure-baseline
log_field_mapping: [pool]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준"
- name: hikaricp.connections.active
type: gauge
unit: total
tags:
- name: pool
cardinality_limit: 5
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "active == max for 1m"
owner_branch: feature-persistence-failure-baseline
log_field_mapping: [pool]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — Histogram Buckets/Percentile "DB query: same"
- name: db.query.duration
type: timer
unit: seconds
tags:
- name: operation
cardinality_limit: 20
allowed_values: [select, insert, update, delete, batch, ddl, other]
- name: outcome
cardinality_limit: 3
allowed_values: [SUCCESS, FAILURE, TIMEOUT]
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "p99 > 1s for 10m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [operation, outcome, duration_ms]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Background job / async executor ===
# source: feature-background-job-async-contract — Decisionized Work Items "saturation policy"
# "AbortPolicy default (core=10, max=50, queue=200)"
- name: executor.saturation
type: gauge
unit: total
tags:
- name: executor_name
cardinality_limit: 10
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "queue size > 80% capacity for 5m"
p1: "rejection rate > 0 for 1m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [executor_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-background-job-async-contract — Decisionized Work Items "saturation | bounded executor + rejection log"
- name: executor.rejected.total
type: counter
unit: total
tags:
- name: executor_name
cardinality_limit: 10
- name: policy
cardinality_limit: 3
allowed_values: [AbortPolicy, CallerRunsPolicy, DiscardPolicy]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "rejection_count > 0 for 1m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [executor_name, policy]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-background-job-async-contract — Decisionized Work Items "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted"
- name: job.retry.total
type: counter
unit: total
tags:
- name: job_name
cardinality_limit: 50
- name: outcome
cardinality_limit: 4
allowed_values: [SUCCESS, RETRY, EXHAUSTED, DLQ]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "EXHAUSTED rate > 1% for 10m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [job_name, outcome, retry_attempt]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-background-job-async-contract — "DLQ after exhausted attempts"
- name: job.dlq.total
type: counter
unit: total
tags:
- name: job_name
cardinality_limit: 50
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "DLQ rate sustained > 0 for 5m"
owner_branch: feature-background-job-async-contract
log_field_mapping: [job_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Distributed lock ===
# source: feature-distributed-lock-contract D7 — "metric lock.acquisition (tag: outcome =
# acquired/timeout/error) — 신규 제안" / D5 — try-lock + 유한 waitTime + lease(TTL). 분산
# 상호배제(distributedLockProvider) 획득 시도 결과를 센다. key 는 tag 로 넣지 않는다
# (무한 cardinality — 위 전역 금지 규칙). timeout outcome 은 LOCK_ACQUISITION_TIMEOUT 발생과 1:1.
- name: lock.acquisition
type: counter
unit: total
tags:
- name: outcome
cardinality_limit: 3
allowed_values: [acquired, timeout, error]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "timeout rate > 5% for 10m"
owner_branch: feature-distributed-lock-contract
log_field_mapping: [outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-distributed-lock-contract §Edge / D5 (SI-LOCK-C5) — "lease 만료 후 unlock →
# ConcurrentModificationException — 삼킴 금지, 로그+metric 후 정상 흐름 복귀". Counts releases that
# found the lease already expired (the JdbcLock row was reclaimed by another instance before
# the holder called close()). A sustained nonzero rate means lease TTL is shorter than real
# critical-section duration — raise APP/lease TTL or shorten the protected work. Not an
# acquisition outcome, hence a separate counter from lock.acquisition.
- name: lock.lease.expired
type: counter
unit: total
tags: []
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "lease-expired rate sustained > 0 for 10m"
owner_branch: feature-distributed-lock-contract
log_field_mapping: []
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Outbox publisher ===
# source: feature-domain-event-outbox-contract — Outbox Defaults
# "DB outbox table with eventId, aggregateId, eventType, payload, occurredAt, status, attemptCount, nextAttemptAt"
- name: outbox.publisher.published.total
type: counter
unit: total
tags:
- name: event_type
cardinality_limit: 50
- name: outcome
cardinality_limit: 4
allowed_values: [PUBLISHED, FAILED, DEAD, IN_FLIGHT]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "FAILED rate > 1% for 10m"
owner_branch: feature-domain-event-outbox-contract
log_field_mapping: [event_type, outcome, event_id]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-domain-event-outbox-contract — row status enum PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD
- name: outbox.publisher.lag
type: gauge
unit: seconds
tags:
- name: event_type
cardinality_limit: 50
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "lag > 60s for 10m"
p1: "lag > 300s for 5m"
owner_branch: feature-domain-event-outbox-contract
log_field_mapping: [event_type]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-domain-event-outbox-contract — row status enum + Outbox Defaults attemptCount
- name: outbox.pending.size
type: gauge
unit: total
tags:
- name: status
cardinality_limit: 5
allowed_values: [PENDING, IN_FLIGHT, PUBLISHED, FAILED, DEAD]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "PENDING size growing for 10m"
owner_branch: feature-domain-event-outbox-contract
log_field_mapping: [status]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Cache ===
# source: feature-cache-consistency-contract — Decisionized Work Items "cache pattern | cache-aside default"
- name: cache.gets.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: result
cardinality_limit: 3
allowed_values: [hit, miss, error]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "hit_ratio < baseline 0.5x for 1h"
owner_branch: feature-cache-consistency-contract
log_field_mapping: [cache_name, result]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-cache-consistency-contract — "invalidation = after-commit only", "invalidation 실패가 조용히 무시되면 실패"
- name: cache.invalidations.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: outcome
cardinality_limit: 3
allowed_values: [SUCCESS, FAILURE, SKIPPED]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "FAILURE rate > 0 for 5m"
owner_branch: feature-cache-consistency-contract
log_field_mapping: [cache_name, outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability — optional bounded cache-only L1
- name: cache.local.requests.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: result
cardinality_limit: 4
allowed_values: [hit, miss, error, bypass]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "bypass or error rate above baseline for 15m"
owner_branch: redis-production-capability
log_field_mapping: [cache_name, result]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability — local stale-age bound
- name: cache.local.entry.age.seconds
type: timer
unit: seconds
tags:
- name: cache_name
cardinality_limit: 50
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p3: "p99 approaches configured local TTL for 30m"
owner_branch: redis-production-capability
log_field_mapping: [cache_name]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability — bounded invalidation and generation reconciliation
- name: cache.local.maintenance.total
type: counter
unit: total
tags:
- name: cache_name
cardinality_limit: 50
- name: event
cardinality_limit: 14
allowed_values:
- evict_cardinality
- evict_weight
- evict_ttl
- evict_invalidation
- flush_invalidation
- reconcile_generation_changed
- reconcile_unchanged
- reconcile_error
- subscriber_disconnected
- subscriber_overflow
- subscriber_malformed
- subscriber_publish_success
- subscriber_publish_error
- other
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "reconcile_error, subscriber_overflow, or sustained disconnects for 5m"
owner_branch: redis-production-capability
log_field_mapping: [cache_name, event]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — closed semantic operation outcomes
- name: redis.capability.operations.total
type: counter
unit: total
tags:
- name: capability
cardinality_limit: 6
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime]
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: operation
cardinality_limit: 24
allowed_values:
- lookup
- record
- invalidate
- refresh_claim
- refresh_release
- rate_evaluate
- idempotency_claim
- idempotency_start
- idempotency_renew
- idempotency_complete
- idempotency_fail
- idempotency_release
- idempotency_inspect
- lease_acquire
- lease_inspect
- lease_renew
- lease_release
- session_create
- session_inspect
- session_save
- session_touch
- session_revoke
- session_rotate
- route_command
- name: redis_outcome
cardinality_limit: 15
allowed_values:
- success
- hit
- miss
- denied
- contended
- conflict
- incompatible
- unavailable
- overloaded
- closed
- indeterminate
- stale
- skipped
- tombstoned
- absolute_expired
- name: certainty
cardinality_limit: 3
allowed_values: [definite, not_applied, indeterminate]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required coordination/session unavailable or indeterminate mutation sustained for 2m"
p2: "optional cache unavailable or overloaded above baseline for 5m"
owner_branch: redis-production-capability
log_field_mapping: [capability, role, operation, redis_outcome, certainty]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — monotonic semantic operation duration
- name: redis.capability.duration.seconds
type: timer
unit: seconds
tags:
- name: capability
cardinality_limit: 6
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime]
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: operation
cardinality_limit: 24
allowed_values:
- lookup
- record
- invalidate
- refresh_claim
- refresh_release
- rate_evaluate
- idempotency_claim
- idempotency_start
- idempotency_renew
- idempotency_complete
- idempotency_fail
- idempotency_release
- idempotency_inspect
- lease_acquire
- lease_inspect
- lease_renew
- lease_release
- session_create
- session_inspect
- session_save
- session_touch
- session_revoke
- session_rotate
- route_command
- name: redis_outcome
cardinality_limit: 15
allowed_values:
- success
- hit
- miss
- denied
- contended
- conflict
- incompatible
- unavailable
- overloaded
- closed
- indeterminate
- stale
- skipped
- tombstoned
- absolute_expired
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "p99 approaches the configured command or caller deadline for 10m"
owner_branch: redis-production-capability
log_field_mapping: [capability, role, operation, redis_outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — admission rejected before command ownership
- name: redis.capability.admission.rejected.total
type: counter
unit: total
tags:
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: admission
cardinality_limit: 2
allowed_values: [rejected_saturated, rejected_closed]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required role rejection sustained above zero for 2m"
p2: "optional cache saturation sustained for 5m"
owner_branch: redis-production-capability
log_field_mapping: [role, admission]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — bounded admitted command count observation
- name: redis.capability.inflight.total
type: gauge
unit: total
tags:
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: state
cardinality_limit: 3
allowed_values: [idle, active, saturated]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "saturated series remains nonzero for 5m"
owner_branch: redis-production-capability
log_field_mapping: [role, state]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — observations of exact sanitized RoleHealth
- name: redis.capability.readiness.total
type: counter
unit: total
tags:
- name: capability
cardinality_limit: 5
allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session]
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: state
cardinality_limit: 3
allowed_values: [available, unavailable, overloaded]
- name: reason
cardinality_limit: 11
allowed_values:
- command_unavailable
- route_closed
- semantic_probe_succeeded
- semantic_read_write_failed
- semantic_program_acl_denied
- semantic_program_failed
- server_version_unsupported
- semantic_probe_in_progress
- semantic_observation_stale
- command_saturated
- recent_command_failure
- name: requirement
cardinality_limit: 2
allowed_values: [optional, required]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required coordination/session unavailable for 2m"
p2: "optional cache unavailable or overloaded for 5m"
owner_branch: redis-production-capability
log_field_mapping: [capability, role, state, reason, requirement]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: redis-production-capability Task 16 — bounded router shutdown drain result
- name: redis.capability.lifecycle.drain.total
type: counter
unit: total
tags:
- name: role
cardinality_limit: 3
allowed_values: [cache, coordination, session]
- name: drain_outcome
cardinality_limit: 3
allowed_values: [drained, forced_after_timeout, interrupted]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "required role forced_after_timeout or interrupted during shutdown"
p2: "optional cache forced close during shutdown"
owner_branch: redis-production-capability
log_field_mapping: [role, drain_outcome]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Log appender ===
# source: feature-log-management-contract — Sampling Policy (final)
# "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)"
- name: log.appender.dropped.total
type: counter
unit: total
tags:
- name: appender
cardinality_limit: 5
- name: level
cardinality_limit: 2
allowed_values: [INFO, DEBUG]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "dropped > 0 sustained for 10m"
owner_branch: feature-log-management-contract
log_field_mapping: [appender, level]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === Distributed tracing ===
# source: feature-distributed-tracing-contract — decisions
# "trace sampling rate default = prod 1%, staging 10%, dev/local 100%"
- name: tracing.sampling.rate
type: gauge
unit: total
tags:
- name: profile
cardinality_limit: 4
allowed_values: [prod, staging, dev, local]
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "effective rate deviates from configured for 1h"
owner_branch: feature-distributed-tracing-contract
log_field_mapping: [profile]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# === JVM baseline ===
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
- name: jvm.memory.used
type: gauge
unit: bytes
tags:
- name: area
cardinality_limit: 2
allowed_values: [heap, nonheap]
- name: id
cardinality_limit: 10
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p2: "heap used / max > 0.85 for 10m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [area, id]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
- name: jvm.gc.pause
type: timer
unit: seconds
tags:
- name: action
cardinality_limit: 10
- name: cause
cardinality_limit: 10
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p2: "p99 pause > 500ms for 10m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: [action, cause]
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — In scope "JVM/process metric"
- name: jvm.threads.live
type: gauge
unit: total
tags: []
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p3: "thread count > 2x baseline for 30m"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: []
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
# source: feature-metrics-alerting-contract — In scope "JVM/process metric" (process uptime)
- name: process.uptime
type: gauge
unit: seconds
tags: []
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "uptime reset unexpectedly < 60s (crash loop signal)"
owner_branch: feature-metrics-alerting-contract
log_field_mapping: []
compatibility_impact: additive
required_test: contract-verification:metrics-cardinality
@@ -0,0 +1,118 @@
# 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
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-managed-integrity
claimed_level: R1
evidence_revision: batch-b-local-r1
evidence_expires_on: ""
required_tasks:
- ":adapter:outbound:objectstorage:check"
limitations:
- single-process control CAS only; no multi-node linearizability
- fsync and atomic move tests do not prove power-loss durability
- local development provider is forbidden in production profiles
- card_id: object-storage-managed-upload-multipart
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- multipart publication protocol is not implemented
- card_id: object-storage-managed-download
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-managed-integrity
claimed_level: R1
evidence_revision: batch-b-local-r1
evidence_expires_on: ""
required_tasks:
- ":adapter:outbound:objectstorage:check"
limitations:
- local functional full and range reads are not production-provider qualification
- no multi-node or power-loss durability claim
- card_id: object-storage-direct-upload-single
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- direct grant provider and public inbound endpoint are not implemented
- card_id: object-storage-direct-upload-multipart
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- direct multipart session and public inbound endpoint are not implemented
- card_id: object-storage-direct-download
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- direct download grant and public inbound endpoint are not implemented
- card_id: object-storage-quarantine-publication
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- scanner handoff and verdict fencing are not implemented
- card_id: object-storage-retention
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- retention and legal-hold provider enforcement are not implemented
- privileged purge composition remains intentionally empty
- card_id: object-storage-reconciliation
provider_type: filesystem-local-dev
provider_version: jdk-21
destination_profile: local-unimplemented
claimed_level: R0
evidence_revision: batch-b-contract-r0
evidence_expires_on: ""
required_tasks:
- ":application-core:check"
limitations:
- local create resolution is single-process functional evidence only
- production response-loss and multi-node reconciliation are not implemented
+323
View File
@@ -0,0 +1,323 @@
# Registry: Secrets Classification
# SSOT: wiki/projects/ca-tmpl/registries/secrets-classification.yaml
# Schema owner: feature-contract-registry-governance
# Owner branch: feature-secrets-config-source-contract
# Last updated: 2026-05-22
#
# Conventions:
# - 3-tier classification (feature-secrets-config-source-contract 2026-05-22):
# public-config | sensitive-config | secret
# - `secret` rows: prod_default 항상 null. dev fake 식별자는 `__LOCAL_DEV_` prefix
# (feature-secrets-config-source-contract 2026-05-22: "dev/local sentinel value prefix = __LOCAL_DEV_").
# - prod profile에서 `__LOCAL_DEV_` prefix 발견 시 startup fail
# (feature-secrets-config-source-contract 2026-05-22).
# - Masking 기본 = `full_except_last_4` (feature-secrets-config-source-contract 2026-05-22:
# "full mask except last 4 chars for non-secret tokens"). 진짜 secret(password/private key)은 `full`.
# - Naming suffix는 보조 신호 (feature-secrets-config-source-contract: "_TOKEN, _KEY, _PASSWORD").
# - public-config 항목은 env-keys.yaml에서 직접 정의되며 본 파일에는 reference row만 둠.
secrets:
# === Tier 3: secret (true secret — password/private-key/HMAC-salt) ===
- name: APP_DATASOURCE_PASSWORD
# source: feature-secrets-config-source-contract 2026-05-22
# "DB credential은 dual-bind 60s" + "__LOCAL_DEV_FAKE_DB_PASSWORD" 예시
classification: secret
source: secret-manager
rotation_policy: dual-bind-60s
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:db-password-no-leak-in-actuator
- name: APP_SECURITY_JWT_SIGNING_KEY
# source: feature-secrets-config-source-contract 2026-05-22
# "JWT signing key는 24h overlap window 유지 (security branch와 cross-link)"
# + feature-security-operational-baseline "rotation overlap window = 새 kid 도입 → 24h 동안 old kid 병행"
classification: secret
source: secret-manager
rotation_policy: overlap-24h
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:jwt-signing-key-rotation-overlap
- name: APP_SECURITY_OAUTH_CLIENT_SECRET
# source: feature-secrets-config-source-contract 2026-05-22
# "secret classification은 ... naming pattern은 보조(suffix _TOKEN, _KEY, _PASSWORD)"
# + feature-security-operational-baseline "JWT Resource Server를 baseline security model" (OAuth 자격 증명 분류)
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:oauth-client-secret-no-leak
- name: APP_EXTERNAL_API_KEY
# source: feature-secrets-config-source-contract 2026-05-22
# "external API key는 application restart 시 reload"
# (per-dependency suffix는 adapter 등록 시 추가; 본 row는 baseline 분류 정의)
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:external-api-key-no-leak
- name: APP_CACHE_REDIS_PASSWORD
# source: feature-secrets-config-source-contract 2026-05-22
# 3-tier classification "secret" + feature-integration-adapter-templates "Redis | disabled optional module"
# (Redis enabled + auth 사용 시 secret으로 분류)
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:redis-password-no-leak
- name: APP_CACHE_REDIS_TRUST_PEM
# Public CA bundle content, but integrity-sensitive and supplied by the mounted environment.
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:redis-trust-reference-no-leak
- name: APP_CACHE_REDIS_KEY_HMAC_SECRET
# Stable cache-key HMAC material. It is distinct from the Redis authentication credential.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: codex-phase-a-ci-recovery
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:redis-key-hmac-no-leak
- name: APP_RATE_LIMIT_REDIS_PASSWORD
# Dedicated coordination-role Redis credential. It is never inherited from cache Redis.
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-distributed-rate-limit
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:rate-limit-redis-password-no-leak
- name: APP_RATE_LIMIT_REDIS_TRUST_PEM
# Coordination-role CA bundle content; integrity-sensitive but not credential material.
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:rate-limit-redis-trust-reference-no-leak
- name: APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET
# Stable private-key derivation material for rate-limit subjects and policy revisions.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-distributed-rate-limit
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:rate-limit-redis-key-hmac-no-leak
- name: APP_SESSION_REDIS_PASSWORD
# Dedicated session-role ACL credential; never shared implicitly with cache or coordination.
classification: secret
source: secret-manager
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:session-redis-password-no-leak
- name: APP_SESSION_REDIS_TRUST_PEM
# Session-role CA bundle content; integrity-sensitive but not credential material.
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:session-redis-trust-reference-no-leak
- name: APP_SESSION_REDIS_KEY_HMAC_SECRET
# Stable private derivation material for pseudonymous Redis session keys.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:session-redis-key-hmac-no-leak
- name: APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET
# Owner-safe request-replay keys must not expose tenant/scope/request identifiers.
classification: secret
source: secret-manager
rotation_policy: cold-cutover-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability-completion
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:idempotency-redis-key-hmac-no-leak
- name: APP_LEASE_REDIS_KEY_HMAC_SECRET
# Efficiency-lease resource and owner scopes use a dedicated derivation key.
classification: secret
source: secret-manager
rotation_policy: cold-cutover-restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: redis-production-capability-completion
masking_rule: full
compatibility_impact: additive
required_test: secrets-contract:lease-redis-key-hmac-no-leak
- name: APP_PRIVACY_PSEUDONYMIZATION_SALT
# source: feature-data-retention-privacy-contract 2026-05-22
# "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일.
# rotation 시 old salt 90일 retain (lookup 가능)."
# + feature-tenant-context-policy "tenant identifier는 raw PII가 아니어야 하며 ... pseudonymized id"
classification: secret
source: secret-manager
rotation_policy: salt-rotation-90d
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-data-retention-privacy-contract
masking_rule: full
compatibility_impact: breaking
required_test: secrets-contract:pseudonymization-salt-rotation
# === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) ===
- name: APP_NOTIFICATION_SLACK_WEBHOOK_URL
# source: feature-integration-adapter-templates 2026-05-22
# "Slack | disabled optional module | notification failure policy"
# Slack webhook URL은 token을 path에 포함하므로 sensitive-config (URL 형태이지만 secret과 동급 취급)
classification: sensitive-config
source: secret-manager
rotation_policy: manual
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:slack-webhook-no-leak
- name: APP_SECURITY_GOOGLE_OAUTH_CLIENT_ID
# source: feature-secrets-config-source-contract 2026-05-22
# "sensitive-config" tier (id이지만 노출 제한)
# + feature-integration-adapter-templates "Google Email | disabled optional module"
classification: sensitive-config
source: mounted-env
rotation_policy: manual
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: behavior-change
required_test: secrets-contract:google-oauth-client-id-masked
- name: APP_DATASOURCE_USERNAME
# source: feature-secrets-config-source-contract 2026-05-22 — "sensitive-config" tier
# (DB user는 password와 함께 노출되면 위험하므로 sensitive-config)
classification: sensitive-config
source: mounted-env
rotation_policy: dual-bind-60s
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:datasource-username-masked-in-actuator
- name: APP_DATASOURCE_URL
# source: feature-secrets-config-source-contract 2026-05-22 — JDBC URL은 host/db 포함하므로 sensitive-config
# (env-keys.yaml에서는 public-config 처리; 본 파일에서는 노출 통제 관점에서 sensitive로 재분류 — masking 기준 명시 목적)
classification: sensitive-config
source: mounted-env
rotation_policy: restart-only
prod_default: null
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: feature-secrets-config-source-contract
masking_rule: full_except_last_4
compatibility_impact: breaking
required_test: secrets-contract:datasource-url-masked-in-actuator
# === Tier 1: public-config (reference only — full row in env-keys.yaml) ===
- name: APP_PROFILE
# source: feature-env-driven-runtime-configuration — public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#APP_PROFILE
- name: APP_NAME
# source: feature-env-driven-runtime-configuration — public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#APP_NAME
- name: SERVER_PORT
# source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#SERVER_PORT
- name: SPRING_PROFILES_ACTIVE
# source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-env-driven-runtime-configuration
masking_rule: none
reference: env-keys.yaml#SPRING_PROFILES_ACTIVE
- name: OTEL_EXPORTER_OTLP_ENDPOINT
# source: feature-distributed-tracing-contract — public-config tier reference
classification: public-config
source: application-yml
owner_branch: feature-distributed-tracing-contract
masking_rule: none
reference: env-keys.yaml#OTEL_EXPORTER_OTLP_ENDPOINT
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — ADAPTER_DISABLED (런타임 어댑터 비활성화 호출)
category: INTERNAL
error_codes: [ADAPTER_DISABLED]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: ADAPTER_DISABLED (`runbook://adapter/adapter-disabled`)
## Symptoms
- HTTP 500 with `error.code=ADAPTER_DISABLED`
- Code invoked an optional adapter (Kafka/Redis/Slack/Email) that is disabled in this deployment
## Diagnosis
- Check adapter name in log (`adapter_name` field)
- Review deployment config — which optional adapters are enabled?
## Action
- Enable the adapter in deployment configuration (env flag)
- Or update application logic to skip disabled-adapter paths
## Escalation
- Escalate to deployment team if adapter should be enabled but isn't
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_AUDIENCE_MISMATCH (대상 불일치)
category: AUTH
error_codes: [AUTH_AUDIENCE_MISMATCH]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_AUDIENCE_MISMATCH (`runbook://auth/audience-mismatch`)
## Symptoms
- HTTP 401 with `error.code=AUTH_AUDIENCE_MISMATCH`
- Token `aud` claim does not include this service's expected audience
## Diagnosis
- Check token `aud` claim value
- Compare against configured `spring.security.oauth2.resourceserver.jwt.audiences`
## Action
- Verify client is requesting tokens scoped to the correct audience
- Update audience configuration if service identifier changed
## Escalation
- Escalate to auth-platform team if misconfiguration is system-wide
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_CLAIM_MAPPING_FAILED (클레임 매핑 실패)
category: AUTH
error_codes: [AUTH_CLAIM_MAPPING_FAILED]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_CLAIM_MAPPING_FAILED (`runbook://auth/claim-mapping-failed`)
## Symptoms
- HTTP 401 with `error.code=AUTH_CLAIM_MAPPING_FAILED`
- Token validated but required claims (sub, roles, tenant) missing or unexpected type
## Diagnosis
- Inspect token payload claims via logs
- Check claim extractor configuration
## Action
- Verify IdP token template includes required claims
- Update claim mapping configuration if IdP schema changed
## Escalation
- Escalate to auth-platform team if IdP changed claim schema
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_ISSUER_MISMATCH (발급자 불일치)
category: AUTH
error_codes: [AUTH_ISSUER_MISMATCH]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_ISSUER_MISMATCH (`runbook://auth/issuer-mismatch`)
## Symptoms
- HTTP 401 with `error.code=AUTH_ISSUER_MISMATCH`
- Token `iss` claim does not match configured expected issuer
## Diagnosis
- Compare token `iss` against `spring.security.oauth2.resourceserver.jwt.issuer-uri`
- Check if IdP environment changed
## Action
- Update issuer config if IdP migrated
- Reject tokens from unexpected issuers
## Escalation
- Escalate to platform-security if unexpected issuer detected
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_JWKS_UNAVAILABLE (JWKS 엔드포인트 장애)
category: TRANSIENT_DEPENDENCY
error_codes: [AUTH_JWKS_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_JWKS_UNAVAILABLE (`runbook://auth/jwks-unavailable`)
## Symptoms
- HTTP 503 with `error.code=AUTH_JWKS_UNAVAILABLE`
- All authentication failing; JWKS refresh attempts failing
## Diagnosis
- Check IdP JWKS endpoint health: `curl -sf https://<idp-host>/.well-known/jwks.json`
- Check network connectivity from app pods to IdP
## Action
- Enable cached JWKS fallback if available
- Coordinate with IdP team for restoration
## Escalation
- P1 page: IdP team immediately if JWKS endpoint unreachable > 2 minutes
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_KID_UNKNOWN (키 ID 미인식)
category: AUTH
error_codes: [AUTH_KID_UNKNOWN]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_KID_UNKNOWN (`runbook://auth/kid-unknown`)
## Symptoms
- HTTP 401 with `error.code=AUTH_KID_UNKNOWN`, `retryable=true`
- Token `kid` header not present in cached JWKS
## Diagnosis
- Check if IdP key rotation occurred recently
- Verify JWKS cache TTL and refresh timing
## Action
- Force JWKS cache refresh
- Confirm new key is published in IdP JWKS endpoint
## Escalation
- Escalate to IdP team if new kid not appearing in JWKS after 10 minutes
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — INTERNAL_AUTH_MISCONFIGURATION (공개 경로 설정 오류)
category: INTERNAL
error_codes: [INTERNAL_AUTH_MISCONFIGURATION]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: INTERNAL_AUTH_MISCONFIGURATION (`runbook://auth/public-path-misconfiguration`)
## Symptoms
- HTTP 500 with `error.code=INTERNAL_AUTH_MISCONFIGURATION`
- Security filter misconfiguration detected at runtime
## Diagnosis
- Check `verifyPublicPathSnapshot` output in CI
- Review recent changes to `SecurityConfig` or `application.yml` public path list
## Action
- Revert misconfigured public path change
- Run `./gradlew verifyPublicPathSnapshot` to compare snapshot
## Escalation
- P1 immediate: if auth bypass is possible due to misconfiguration
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_EXPIRED (토큰 만료)
category: AUTH
error_codes: [AUTH_TOKEN_EXPIRED]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_EXPIRED (`runbook://auth/token-expired`)
## Symptoms
- HTTP 401 with `error.code=AUTH_TOKEN_EXPIRED`
- Spike may indicate clock skew or long-lived token usage
## Diagnosis
- Check `exp` claim vs server clock
- Check NTP sync on token-issuing host
## Action
- Client must refresh tokens before expiry
- Verify clock skew tolerance is configured (default 60s)
## Escalation
- Escalate if spike is widespread or clock drift is confirmed
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_INVALID_SIGNATURE (서명 검증 실패)
category: AUTH
error_codes: [AUTH_TOKEN_INVALID_SIGNATURE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_INVALID_SIGNATURE (`runbook://auth/token-invalid-signature`)
## Symptoms
- HTTP 401 with `error.code=AUTH_TOKEN_INVALID_SIGNATURE`
- `log_level=ERROR` — may indicate forged tokens or wrong signing key
## Diagnosis
- Check if JWKS endpoint returned a new key set
- Check for token forgery attempts in logs
## Action
- Verify JWKS key IDs match token headers
- Alert security team if forgery suspected
## Escalation
- Immediate P1 escalation if forgery indicators present
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_MALFORMED (토큰 파싱 실패)
category: AUTH
error_codes: [AUTH_TOKEN_MALFORMED]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_MALFORMED (`runbook://auth/token-malformed`)
## Symptoms
- HTTP 401 responses with `error.code=AUTH_TOKEN_MALFORMED`
- Token present but fails JWT parse (not 3-part, non-base64, etc.)
## Diagnosis
- Inspect raw Authorization header value in logs
- Check if token generation tooling has a bug
## Action
- Identify source of malformed tokens
- Fix or update client token generation
## Escalation
- Escalate if spike suggests infrastructure issue
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
category: AUTH
error_codes: [AUTH_TOKEN_MISSING]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTH_TOKEN_MISSING (`runbook://auth/token-missing`)
## Symptoms
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
- Client missing Authorization header or Bearer token
## Diagnosis
- Check request logs for missing Authorization header
- Verify client SDK configuration
## Action
- Confirm API clients are sending Authorization header
- Check gateway/proxy configuration for header stripping
## Escalation
- Escalate if widespread or affecting critical workflows
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,69 @@
---
title: Runbook — JWT key rotation 시 인증 실패 spike
category: AUTH
error_codes: [AUTH_TOKEN_EXPIRED, AUTH_KID_UNKNOWN, AUTH_JWKS_UNAVAILABLE, AUTH_TOKEN_INVALID_SIGNATURE]
severity: P1
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: JWT key rotation 시 인증 실패 spike
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `auth_401_error_rate_high` 또는 `jwks_refresh_failure_spike`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `runbook_link`, `dependency_name`
- 임계: 401 error rate > 5% 5분 지속 OR JWKS refresh failure count > 10건/분
## 2. First Response (5분 이내)
### Step 1 — 확인
1. JWKS endpoint health check: `curl -sf https://<idp-host>/.well-known/jwks.json | jq '.keys | length'`
2. log query에서 `error.code` 분포 확인 — `AUTH_KID_UNKNOWN` 비중이 높으면 rotation 원인 강력 시사
3. IdP rotation schedule 확인 (직전 24h 내 rotation 이벤트가 있었는지)
### Step 2 — 임시 격리
- JWKS cache TTL을 짧게(예: 60s) 강제하여 새 kid 전파 가속
- 새 kid가 JWKS에 publish되어 있는지 확인. 누락이면 IdP에 republish 요청
## 3. Diagnosis
- log query (Loki/CloudWatch): `{service="auth"} | error.category="AUTH" | dependency_name="jwks-endpoint"`
- metric panel: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`, `auth_kid_unknown_total`
- trace: 실패한 request 1건에서 `traceId` 추출 → IdP outbound span 확인
- 가능한 원인:
- 새 kid가 JWKS에 publish되기 전 token 발급 → 24h overlap window 안에 있는지 확인
- JWKS endpoint 장애 (5xx, timeout) → IdP status page 확인
- 시계 skew로 인한 만료 오판 → NTP sync 상태 확인
## 4. Mitigation
- 단기: old kid를 임시 재허용 (rollback). overlap window를 48h로 일시 확장
- IdP에 새 JWKS publish 재시도 요청
- 장기: rotation 절차에 "publish → 24h 대기 → switch" 단계 강제. observability에 kid 분포 metric 추가
## 5. Escalation
- P2 → P1 격상 조건: 401 error rate > 20% 또는 다중 tenant에 동시 발생
- 다음 on-call로 page: 10분 내 회복 안 되면 IdP team 또는 platform-security team page
## 6. Recovery / Verification
- 회복 확인 metric: `auth_401_error_rate < 1%` 5분 지속, `AUTH_KID_UNKNOWN` 건수 0
- post-incident:
- rotation 절차 RCA 작성
- JWKS overlap window 정책 문서 업데이트
- kid 분포 dashboard 영구화
## 7. Related
- error-codes.yaml rows: `AUTH_TOKEN_EXPIRED`, `AUTH_KID_UNKNOWN`, `AUTH_JWKS_UNAVAILABLE`, `AUTH_TOKEN_INVALID_SIGNATURE`
- metrics.yaml: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`
- 관련 branch: [[feature-security-operational-baseline]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 IdP 종류·rotation 정책·JWKS endpoint URL·dashboard 링크로 보강 필요.
@@ -0,0 +1,72 @@
---
title: Runbook — cross-tenant 접근 시도 감지
category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, AUTHZ_TENANT_MISMATCH]
severity: P2
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: cross-tenant 접근 시도 감지
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `authz_cross_tenant_violation` 또는 `authz_403_spike`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `principal_id_pseudonymized`, `tenant_id`, `runbook_link`
- 임계:
- P2: 403 with `error.code=AUTHZ_TENANT_MISMATCH` > 10건/5분
- P1 격상: 동일 principal에서 3개 이상 tenant 시도 OR 5분 내 100건 초과
## 2. First Response (5분 이내)
### Step 1 — 확인
1. log query로 위반 principal 식별 (pseudonymized): `error.code=AUTHZ_TENANT_MISMATCH`
2. principal의 정상 tenant scope 확인 (IdP claim 또는 entitlement table)
3. `CROSS_TENANT_ADMIN` capability 보유 여부 확인 — 보유자라면 false positive 가능성
### Step 2 — 임시 격리
- 명백한 위반 패턴이면 principal session 강제 만료 (token revocation list 추가)
- security incident channel 통보 (`#sec-incident`)
- 위반 request의 source IP / user-agent 기록
## 3. Diagnosis
- log query: `{service="api"} | error.category="AUTHZ" | principal_id_pseudonymized="<hash>"`
- metric panel: `authz_denied_total{reason="tenant_mismatch"}`, `authz_principal_tenant_distribution`
- trace: 위반 request의 `traceId`로 호출 chain 확인. token claim의 `tenant_id`와 요청 path의 `tenant_id` 비교
- 가능한 원인:
- account takeover (계정 탈취) → 즉시 session revoke + 비밀번호 reset 요구
- client bug (잘못된 tenant id 전송) → product team에 통보
- 정상 admin operation 누락된 capability → entitlement 보정
## 4. Mitigation
- 단기: principal session revoke, source IP rate-limit 강화
- 위반이 client bug면 client patch release 협조
- 장기: tenant boundary 검증 layer를 controller가 아닌 repository 진입점에서 강제 ([[feature-repository-access-permission-contract]])
## 5. Escalation
- 다음 on-call로 page: 보안 incident channel 즉시 page. 5분 내 security on-call 응답 없으면 CISO escalation
- legal/compliance 통보 필요 여부 판단 (개인정보 noted시)
## 6. Recovery / Verification
- 회복 확인 metric: `AUTHZ_TENANT_MISMATCH` 건수 정상 baseline 복귀
- post-incident:
- account takeover면 forensics 수행 + audit log 보존
- cross-tenant 검증 unit test 추가
- 위반 패턴 detection rule 영구화
## 7. Related
- error-codes.yaml rows: `AUTHZ_INSUFFICIENT_PERMISSION`, `AUTHZ_TENANT_MISMATCH`
- metrics.yaml: `authz_denied_total`, `authz_principal_tenant_distribution`
- 관련 branch: [[feature-tenant-context-policy]], [[feature-repository-access-permission-contract]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 tenant 모델·capability 정의·security team 연락 체계로 보강 필요.
@@ -0,0 +1,34 @@
---
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTHZ_INSUFFICIENT_PERMISSION (`runbook://authz/insufficient-permission`)
## Symptoms
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
- Valid token but missing required role or permission
## Diagnosis
- Check user's assigned roles in IdP
- Review endpoint's required permission annotation
## Action
- Grant correct role/permission to user
- Verify endpoint permission requirement is correct
## Escalation
- Escalate to access-management team if bulk users affected
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — AUTHZ_TENANT_MISMATCH (테넌트 cross-access 시도)
category: AUTHZ
error_codes: [AUTHZ_TENANT_MISMATCH]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: AUTHZ_TENANT_MISMATCH (`runbook://authz/tenant-mismatch`)
## Symptoms
- HTTP 403 with `error.code=AUTHZ_TENANT_MISMATCH`
- `log_level=ERROR` — cross-tenant access attempt detected
## Diagnosis
- Extract `traceId`, check `X-Tenant-Id` vs token tenant claim
- Determine if this is misconfigured client or intentional attack
## Action
- Block repeat offenders at gateway level
- Alert security team for investigation
## Escalation
- P1 if confirmed malicious cross-tenant access attempt
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — CACHE_STAMPEDE_LOCK_TIMEOUT (캐시 스탬피드 락 타임아웃)
category: TRANSIENT_DEPENDENCY
error_codes: [CACHE_STAMPEDE_LOCK_TIMEOUT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: CACHE_STAMPEDE_LOCK_TIMEOUT (`runbook://cache/stampede-lock-timeout`)
## Symptoms
- HTTP 503 with `error.code=CACHE_STAMPEDE_LOCK_TIMEOUT`
- Multiple concurrent cache misses on same key; lock contention
## Diagnosis
- Check cache hit ratio metrics
- Identify cache keys with high miss rates
## Action
- Verify stampede lock TTL is configured appropriately
- Pre-warm cache for high-traffic keys on startup
## Escalation
- Escalate if backend load spike accompanies stampede
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — CACHE_UNAVAILABLE (캐시 연결 불가)
category: TRANSIENT_DEPENDENCY
error_codes: [CACHE_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: CACHE_UNAVAILABLE (`runbook://cache/unavailable`)
## Symptoms
- HTTP 503 with `error.code=CACHE_UNAVAILABLE`
- Redis connection errors in logs
## Diagnosis
- Check Redis cluster health
- Verify network connectivity from app to Redis
## Action
- Check Redis sentinel/cluster status
- Enable cache degradation path if configured for optional caches
## Escalation
- P1 if required cache is down and no degradation path exists
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_DEADLOCK (데드락)
category: CONFLICT
error_codes: [DB_DEADLOCK]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_DEADLOCK (`runbook://db/deadlock`)
## Symptoms
- HTTP 409 with `error.code=DB_DEADLOCK`
- SQLState 40P01 in Postgres logs
## Diagnosis
- Check `pg_locks` and `pg_stat_activity` during deadlock
- Identify conflicting transaction lock order
## Action
- Client should retry (retryable=true)
- Fix lock ordering in code if recurring
## Escalation
- Escalate to DBA if deadlock rate is sustained > 1% of transactions
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_IDLE_IN_TX_TIMEOUT (트랜잭션 idle 타임아웃)
category: TRANSIENT_DEPENDENCY
error_codes: [DB_IDLE_IN_TX_TIMEOUT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_IDLE_IN_TX_TIMEOUT (`runbook://db/idle-in-tx-timeout`)
## Symptoms
- HTTP 503 with `error.code=DB_IDLE_IN_TX_TIMEOUT`
- SQLState 25P03; transaction held open too long without activity
## Diagnosis
- Check `idle_in_transaction_session_timeout` Postgres setting
- Look for application-level long-running transaction holders
## Action
- Reduce transaction scope in application code
- Verify `spring.jpa.properties.hibernate.connection.timeout` is bounded
## Escalation
- Escalate to DBA if connection pool exhaustion results
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_QUERY_CANCELED (쿼리 취소)
category: TRANSIENT_DEPENDENCY
error_codes: [DB_QUERY_CANCELED]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_QUERY_CANCELED (`runbook://db/query-canceled`)
## Symptoms
- HTTP 503 with `error.code=DB_QUERY_CANCELED`
- SQLState 57014; query exceeds statement timeout
## Diagnosis
- Check `statement_timeout` in Postgres
- Identify slow queries in `pg_stat_statements`
## Action
- Optimize slow query or add index
- Adjust statement timeout if query is legitimately long
## Escalation
- Escalate to DBA for query optimization if recurring
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_SERIALIZATION_FAILURE (직렬화 실패)
category: CONFLICT
error_codes: [DB_SERIALIZATION_FAILURE]
severity: P3
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_SERIALIZATION_FAILURE (`runbook://db/serialization-failure`)
## Symptoms
- HTTP 409 with `error.code=DB_SERIALIZATION_FAILURE`
- SQLState 40001; high concurrent transaction contention
## Diagnosis
- Check DB transaction isolation level
- Identify hot rows / hot tables under high concurrency
## Action
- Client should retry with exponential backoff (retryable=true)
- Optimize transaction scope if spike is sustained
## Escalation
- Escalate to DBA if sustained serialization failure rate > 5%
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DB_UNAVAILABLE (데이터베이스 연결 불가)
category: TRANSIENT_DEPENDENCY
error_codes: [DB_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DB_UNAVAILABLE (`runbook://db/unavailable`)
## Symptoms
- HTTP 503 with `error.code=DB_UNAVAILABLE`
- SQLState 08* connection errors in logs
## Diagnosis
- Check DB server health and connection pool exhaustion
- Review network connectivity from app pods to DB
## Action
- Check DB primary health; failover to replica if available
- Drain connection pool and reconnect
## Escalation
- P1: immediate if DB primary is down
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_4XX_CLIENT (업스트림 클라이언트 오류)
category: PERMANENT_DEPENDENCY
error_codes: [DEPENDENCY_4XX_CLIENT]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_4XX_CLIENT (`runbook://dependency/4xx-client`)
## Symptoms
- HTTP 502 with `error.code=DEPENDENCY_4XX_CLIENT`
- Upstream returned 401/403/400 — credential, scope, or request format issue
## Diagnosis
- Check upstream response body in logs for error detail
- Verify API credentials and scopes are valid
## Action
- Rotate credentials if expired
- Fix request format if API contract changed
## Escalation
- Escalate to upstream API owner if contract change is suspected
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_5XX_SERVER (업스트림 서버 오류)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_5XX_SERVER]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_5XX_SERVER (`runbook://dependency/5xx-server`)
## Symptoms
- HTTP 502 with `error.code=DEPENDENCY_5XX_SERVER`
- Upstream returned 5xx; transient server-side failure
## Diagnosis
- Check `dependency_name` tag for which upstream is failing
- Review upstream service status page
## Action
- Client should retry (retryable=true)
- Monitor upstream recovery
## Escalation
- P1 if critical upstream is in sustained 5xx state
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_CIRCUIT_OPEN (서킷 브레이커 개방)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_CIRCUIT_OPEN]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_CIRCUIT_OPEN (`runbook://dependency/circuit-open`)
## Symptoms
- HTTP 503 with `error.code=DEPENDENCY_CIRCUIT_OPEN`
- Circuit breaker (Resilience4j) in OPEN state for a dependency
## Diagnosis
- Check Resilience4j circuit breaker metrics for the dependency
- Check upstream health; circuit opens after failure threshold breached
## Action
- Wait for circuit half-open probe (automatic after wait duration)
- Resolve upstream issue to allow circuit to close
## Escalation
- P1 if circuit remains open > 5 minutes on a critical dependency
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_CONNECT_FAILED (외부 의존성 연결 실패)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_CONNECT_FAILED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_CONNECT_FAILED (`runbook://dependency/connect-failed`)
## Symptoms
- HTTP 503 with `error.code=DEPENDENCY_CONNECT_FAILED`
- TCP connection refused or network unreachable to upstream
## Diagnosis
- Check `dependency_name` tag for which upstream is unreachable
- Verify network path and firewall rules
## Action
- Check upstream service availability
- Verify service discovery / DNS resolution
## Escalation
- P1 if upstream is a critical service dependency
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_DNS_FAILED (DNS 조회 실패)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_DNS_FAILED]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_DNS_FAILED (`runbook://dependency/dns-failed`)
## Symptoms
- HTTP 503 with `error.code=DEPENDENCY_DNS_FAILED`
- DNS resolution failure for upstream hostname
## Diagnosis
- Test DNS resolution from app pod: `nslookup <upstream-host>`
- Check cluster DNS (CoreDNS) health
## Action
- Verify upstream hostname configuration
- Check CoreDNS / cluster DNS health
## Escalation
- P1 if cluster DNS is degraded
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+34
View File
@@ -0,0 +1,34 @@
---
title: Runbook — DEPENDENCY_TIMEOUT (외부 의존성 타임아웃)
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_TIMEOUT]
severity: P1
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DEPENDENCY_TIMEOUT (`runbook://dependency/timeout`)
## Symptoms
- HTTP 504 with `error.code=DEPENDENCY_TIMEOUT`
- Upstream service did not respond within configured timeout (default: global 10s)
## Diagnosis
- Check `dependency_name` in log for which upstream is timing out
- Review upstream service latency metrics
## Action
- Check upstream service health
- Verify timeout settings match SLA expectations
## Escalation
- P1 if critical upstream is timing out at scale
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+81
View File
@@ -0,0 +1,81 @@
---
title: Runbook — 외부 의존성 unavailable
category: TRANSIENT_DEPENDENCY
error_codes: [DEPENDENCY_TIMEOUT, DEPENDENCY_CONNECT_FAILED, DEPENDENCY_DNS_FAILED, DEPENDENCY_CIRCUIT_OPEN, DEPENDENCY_5XX_SERVER, CACHE_UNAVAILABLE, DB_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: 외부 의존성 unavailable
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `dependency_error_rate_critical` 또는 `circuit_breaker_open`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `dependency_name`, `dependency_kind`(required|optional), `runbook_link`
- 임계:
- P1: required dependency의 error rate > 50% 1분 OR circuit_open state 활성
- P2: optional dependency degraded (fail-open으로 동작 중)
## 2. First Response (5분 이내)
### Step 1 — 확인
1. `dependency_name` 별 status page 확인 (외부 SaaS면 vendor status, internal이면 해당 service dashboard)
2. log query로 실패 패턴 확인: timeout / connect / DNS / 5xx 중 어떤 모드인지
3. runtime-health Dependency Matrix에서 required vs optional 분류 확인
4. circuit breaker state 확인 (Resilience4j metric)
### Step 2 — 임시 격리
- required dep이면 readiness probe로 traffic 차단 (회복 대기) — cascade failure 방지
- optional dep이면 fail-open with degraded mode 확인. degraded banner를 client에 노출
- DNS failure면 resolver/coredns 상태 확인. cache 강제 flush 검토
## 3. Diagnosis
- log query: `{service="app"} | dependency_name="<name>" | stats count by error.code`
- metric panel:
- `resilience4j_circuitbreaker_state{name="<name>"}`
- `resilience4j_retry_calls_total{kind="failed_without_retry"}`
- `hikaricp_connections_active`, `hikaricp_connections_pending` (DB_UNAVAILABLE)
- `http_client_requests_seconds_count{outcome="SERVER_ERROR"}`
- trace: 실패 request의 outbound span에서 timeout/connect/DNS 분류, target endpoint 확인
- 가능한 원인:
- vendor outage → status page 확인, 회복 대기
- 네트워크 문제 (DNS, security group, NAT) → infra team 확인
- connection pool 고갈 (Hikari) → pool size/timeout 점검
- circuit breaker open 후 half-open 전환 실패 → 수동 reset 검토
- retry-storm으로 인한 self-DoS → retry budget 축소
## 4. Mitigation
- 단기: required면 회복 대기 + traffic 차단, optional이면 degraded mode로 유지
- pool 고갈이면 일시 pool size 상향 + leak detection 활성화
- circuit이 stuck이면 수동 reset (`actuator/circuitbreakerevents`)
- 장기: retry budget·timeout·circuit 임계 재조정, fallback path 보강, vendor SLA 재협상
## 5. Escalation
- 다음 on-call로 page: required dep 5분 내 회복 안 되면 외부 dep team 또는 vendor에 page
- 다중 dep 동시 장애면 incident commander 호출 (네트워크 전반 문제 의심)
## 6. Recovery / Verification
- 회복 확인 metric: dependency error rate < 1% 5분 지속, circuit_breaker_state = CLOSED, pool utilization 정상
- post-incident:
- vendor postmortem 요청 (외부 SaaS면)
- timeout/retry/circuit 설정 재검토
- degraded mode가 사용자 경험에 미친 영향 측정
- chaos test에 해당 시나리오 추가
## 7. Related
- error-codes.yaml rows: `DEPENDENCY_TIMEOUT`, `DEPENDENCY_CONNECT_FAILED`, `DEPENDENCY_DNS_FAILED`, `DEPENDENCY_CIRCUIT_OPEN`, `DEPENDENCY_5XX_SERVER`, `CACHE_UNAVAILABLE`, `DB_UNAVAILABLE`
- metrics.yaml: `resilience4j_circuitbreaker_state`, `hikaricp_connections_active`, `http_client_requests_seconds_count`
- 관련 branch: [[feature-outbound-http-client-baseline]], [[feature-persistence-failure-baseline]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 dependency 목록·required/optional 분류·vendor 연락 체계·circuit/timeout 임계로 보강 필요.
@@ -0,0 +1,34 @@
---
title: Runbook — DOWNLOAD_STREAMING_FAILURE (스트리밍 다운로드 실패)
category: TRANSIENT_DEPENDENCY
error_codes: [DOWNLOAD_STREAMING_FAILURE]
severity: P2
owner: oncall
last_updated: 2026-06-15
status: stub
---
# Runbook: DOWNLOAD_STREAMING_FAILURE (`runbook://file/download-streaming-failure`)
## Symptoms
- HTTP 503 with `error.code=DOWNLOAD_STREAMING_FAILURE`
- Streaming response truncated; backpressure or timeout (60s / 100MB limit)
## Diagnosis
- Check streaming response timeout configuration
- Review download size vs 100MB limit
## Action
- Verify storage backend is reachable
- Check for network congestion on download path
## Escalation
- Escalate to infra if storage backend is degraded
---
> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9)
+74
View File
@@ -0,0 +1,74 @@
---
title: Runbook — 5xx Internal error spike
category: INTERNAL
error_codes: [INTERNAL_ERROR, INTERNAL_AUTH_MISCONFIGURATION, JVM_OOM]
severity: P1
owner: oncall
last_updated: 2026-05-22
status: stub
---
# Runbook: 5xx Internal error spike
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `http_5xx_error_rate_critical`
- alert payload 필수 field: `operation`, `error.code`, `error.category`, `request_id`, `traceId`, `runbook_link`
- 임계: 5xx error rate > 5% 5분 지속 OR > 10% 1분
## 2. First Response (5분 이내)
### Step 1 — 확인
1. 가장 최근 deploy 시각 확인 (CI/CD dashboard, artifact registry digest)
2. JVM metric 확인: heap usage, GC pause, CPU, thread count
3. log에서 실패 request 1건 추출 → `request_id`, `traceId` 확보
4. error.code 분포 확인: `INTERNAL_ERROR` vs `JVM_OOM` vs `INTERNAL_AUTH_MISCONFIGURATION`
### Step 2 — 임시 격리
- 직전 deploy가 의심되면 즉시 rollback (artifact registry에서 직전 image digest pin)
- OOM 패턴이면 affected pod evict → ASG/HPA로 replacement 유도
- LB에서 unhealthy pod 격리 (readiness probe failure 유도)
## 3. Diagnosis
- log query: `{service="app"} | http.status>=500 | stats count by error.code`
- metric panel: `jvm_memory_used_bytes{area="heap"}`, `jvm_gc_pause_seconds`, `process_cpu_seconds_total`, `http_server_requests_seconds_count{status=~"5.."}`
- trace: 실패 request의 `traceId`로 span chain 확인 → stack trace에서 root exception 추출
- heap dump 위치: `/var/tmp/heap/heapdump-<pid>.hprof` (JVM ergonomics: `-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError`)
- 가능한 원인:
- 직전 deploy의 회귀 버그 → rollback
- JVM OOM (메모리 leak 또는 부하 증가) → heap dump 분석
- 외부 의존성 설정 오류 (`INTERNAL_AUTH_MISCONFIGURATION`) → config secret 확인
- thread starvation (pool 고갈) → thread dump (`jstack <pid>`)
## 4. Mitigation
- 단기: 직전 deploy rollback, OOM pod replacement, traffic 일시 감소(scale-out 또는 rate-limit 강화)
- config 오류면 secret/configmap rollback
- 장기: heap dump 기반 leak 수정, capacity planning 재검토
## 5. Escalation
- 다음 on-call로 page: 10분 내 회복 안 되면 incident commander 호출, severity 1 incident 선언
- 데이터 손상 의심되면 DBA team page
## 6. Recovery / Verification
- 회복 확인 metric: 5xx rate < 0.5% 5분 지속, JVM heap usage < 70%, GC pause p99 < 500ms
- post-incident:
- rollback 원인 RCA 작성 (배포 게이트 강화 필요 여부)
- heap dump 분석 결과 공유
- JVM ergonomics(`-XX:MaxRAMPercentage`) 재검토
- rollback 자동화 절차 점검
## 7. Related
- error-codes.yaml rows: `INTERNAL_ERROR`, `INTERNAL_AUTH_MISCONFIGURATION`, `JVM_OOM`
- metrics.yaml: `jvm_memory_used_bytes`, `jvm_gc_pause_seconds`, `http_server_requests_seconds_count`
- 관련 branch: [[feature-operational-error-observability-foundation]], [[feature-container-runtime-contract]]
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 deploy 파이프라인·heap dump 보관 경로·rollback 자동화 명령으로 보강 필요.
+67
View File
@@ -0,0 +1,67 @@
---
title: Runbook — background job dead letter
category: INTERNAL
error_codes: [JOB_DEAD_LETTER]
severity: P1
owner: oncall
last_updated: 2026-06-13
status: stub
---
# Runbook: background job dead letter (`runbook://job/dead-letter`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `job_dead_letter`
- alert payload 필수 field: `error.code=JOB_DEAD_LETTER`, `job_name`, `correlation_id`, `runbook_link`
- 임계: `job.dlq.total` > 0 for 5m (p1) — retry 소진 후 DLQ 진입은 자동 회복이 없으므로 점검 대상
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `JOB_DEAD_LETTER` 라인 확인: `job_name`, 최종 실패 원인 예외, `correlation_id` 추출
2. `job.retry.total{outcome=EXHAUSTED}` 추이로 DLQ 유입 규모 파악
3. DLQ 적재 위치(향후 retry carrier 확정 시 DB 테이블/큐) 확인 — 현재 skeleton은 vocabulary 단계
### Step 2 — 임시 격리
- DLQ는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수
- 비즈니스 크리티컬 job이면 §4의 수동 처분(재처리 또는 폐기)을 우선 수행
## 3. Diagnosis
- log query: `{service="app"} | error.code="JOB_DEAD_LETTER" | stats count by job_name`
- metric panel: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome}`
- 최종 실패 원인 분류:
- poison input(직렬화/계약 위반) → 입력 결함, 재처리해도 실패 — 수정 후 재처리 또는 폐기
- 외부 의존성 장기 outage 중 attempts 소진 → 의존성 회복 후 재처리로 해결 가능
- non-transient error(권한/도메인/스키마)인데 retry된 경우 → 분류기 보강 필요(WAF-REL05-C3: 즉시 DLQ가 정답)
## 4. Mitigation (수동 처분 — 둘 중 하나)
- **재처리 (기본)**: 원인 해소 후 해당 job을 다시 enqueue. 소비자는 멱등(idempotencyKey dedupe) 의무가 있으므로 중복 처리 안전
- **폐기 (영구)**: 작업이 더 이상 유효하지 않으면 DLQ에서 제거. ⚠ 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김
- 장기: poison input 재발 방지(입력 계약 테스트 보강), non-transient error는 retry 없이 즉시 DLQ로 분류
## 5. Escalation
- 처분 판단(재처리 vs 폐기)이 불가하면 해당 job의 비즈니스 오너에게 escalate
- DLQ 누적이 특정 `job_name`에 집중되면 해당 job 코드 오너에게 page
## 6. Recovery / Verification
- 회복 확인: `job.dlq.total` 증가 멈춤, 재처리분의 소비자 dedupe 동작 확인
- post-incident: DLQ 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가
## 7. Related
- error-codes.yaml rows: `JOB_DEAD_LETTER` (INTERNAL, 500, retryable=false)
- metrics.yaml: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome=DLQ}`
- 코드: `app-bootstrap` `async/BackgroundJobMetrics`(retry/DLQ vocabulary 기록 seam — D2/D4)
- 관련 runbook: [[job-executor-rejected]], [[job-timeout]], [[outbox-dead-letter]]
- 관련 branch: [[feature-background-job-async-contract]] (D4 retry/DLQ vocabulary SSOT — outbox/outbound가 consume)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. retry carrier(Spring Retry / Resilience4j / 자체) 확정 후 DLQ 저장소·재처리 절차 보강 필요.
+71
View File
@@ -0,0 +1,71 @@
---
title: Runbook — async executor rejected
category: TRANSIENT_DEPENDENCY
error_codes: [JOB_EXECUTOR_REJECTED]
severity: P1
owner: oncall
last_updated: 2026-06-13
status: stub
---
# Runbook: async executor rejected (`runbook://job/executor-rejected`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `executor_rejected`
- alert payload 필수 field: `error.code=JOB_EXECUTOR_REJECTED`, `executor_name`, `policy`, `runbook_link`
- 임계: `executor.rejected.total` > 0 for 1m (p1) — bounded pool이 saturation으로 task를 거부
- 보조 신호: `executor.saturation` gauge > queue capacity의 80% for 5m (p2)
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `JOB_EXECUTOR_REJECTED` 라인 확인: `executor_name`, `policy=AbortPolicy`, `queue_size` 추출
2. `executor.saturation` 패널에서 큐 점유율 추이 확인 — 일시적 burst인지 지속 saturation인지 판별
3. 동시 유입 원인 파악: 신규 배포 / 트래픽 spike / 다운스트림 지연으로 worker가 장기 점유되는지
### Step 2 — 임시 격리
- AbortPolicy 거부는 호출부에 `RejectedExecutionException`으로 surface됨 — fire-and-forget `@Async` 호출이면 호출부의 async-exception 처리(log/metric)로 흡수됐는지 확인
- 지속 saturation이면 유입 측(트래픽/스케줄러 빈도)을 우선 감속
## 3. Diagnosis
- log query: `{service="app"} | error.code="JOB_EXECUTOR_REJECTED" | stats count by executor_name`
- metric panel: `executor.saturation{executor_name}`, `executor.rejected.total{executor_name, policy}`
- 가능한 원인 우선순위:
- 다운스트림 의존성 지연 → worker가 반납되지 않아 큐 포화 (가장 흔함)
- 트래픽 spike → 정상 부하 한계 초과
- pool 과소 설정 (`APP_ASYNC_EXECUTOR_*`)
- non-idempotent 작업이 retry로 누적
## 4. Mitigation
- 단기: 유입 감속(상위 rate-limit / 스케줄러 interval 확대) 또는 다운스트림 의존성 회복
- pool 재조정(restart-only): `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`
— ⚠ queue를 무한정 키우지 말 것(unbounded 금지, D7). 부하테스트로 수치 검증 후 변경
- CallerRunsPolicy로의 전환은 use-case 차원의 명시적 결정 필요(request thread latency 침식 — TPE-JDK21-C6)
## 5. Escalation
- 다운스트림 의존성 장애가 근본 원인이면 해당 의존성 오너에게 escalate
- pool 재조정으로도 saturation이 지속되면 용량 계획(capacity planning) 오너에게 page
## 6. Recovery / Verification
- 회복 확인: `executor.rejected.total` 증가 멈춤, `executor.saturation` < 80% 정상화
- 거부된 작업의 재처리 경로(멱등 retry / 다음 스케줄 cycle) 정상 동작 확인
## 7. Related
- error-codes.yaml rows: `JOB_EXECUTOR_REJECTED` (TRANSIENT_DEPENDENCY, 503, retryable=true, retry_after 5s)
- metrics.yaml: `executor.rejected.total{executor_name, policy}`, `executor.saturation{executor_name}`
- 코드: `app-bootstrap` `async/AsyncExecutorConfig`(bounded executor), `async/LoggingAbortPolicy`(reject log+metric), `async/BackgroundJobMetrics`
- env: `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`
- 관련 runbook: [[job-timeout]], [[job-dead-letter]]
- 관련 branch: [[feature-background-job-async-contract]] (D7 saturation policy)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 부하 프로파일·alert 채널·pool 수치 확정 시 보강 필요.

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