diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..892aae2 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cd18f62 --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml new file mode 100644 index 0000000..e68b3a6 --- /dev/null +++ b/.github/ci-gate-matrix.yml @@ -0,0 +1,145 @@ +# 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: 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: 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 diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml new file mode 100644 index 0000000..08e1eec --- /dev/null +++ b/.github/dependency-review-config.yml @@ -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 diff --git a/.github/dependency-vulnerability-policy.md b/.github/dependency-vulnerability-policy.md new file mode 100644 index 0000000..6f3a362 --- /dev/null +++ b/.github/dependency-vulnerability-policy.md @@ -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. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d46f76e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## What changed and why + + + +## 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 + + diff --git a/.github/scripts/install-jq.sh b/.github/scripts/install-jq.sh new file mode 100644 index 0000000..92f0f62 --- /dev/null +++ b/.github/scripts/install-jq.sh @@ -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}" diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh new file mode 100644 index 0000000..8a5fc44 --- /dev/null +++ b/.github/scripts/verify-gate-matrix.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" +readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" +readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" +readonly EXPECTED_GATE_COUNT=19 + +if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then + printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 + exit 1 +fi +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}" +} + +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 ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \ + --include='build.gradle'; then + failures+=("gate '${id}' references unregistered Gradle task '${ref}'") + continue + fi + ;; + gradle-plugin-task) + plugin="${ref%@*}" + task="${ref#*@}" + if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then + failures+=("gate '${id}' must use plugin@task for gradle-plugin-task") + continue + fi + if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \ + --include='build.gradle'; 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_body "${workflow_file}" "${job}" | grep -Eqs -- '\./gradlew[[:space:]]+check([[:space:]]|$)'; 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_body "${workflow_file}" "${job}" | grep -Fqs -- "${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' diff --git a/.github/workflows/ci-quality-gates.yml b/.github/workflows/ci-quality-gates.yml new file mode 100644 index 0000000..172db40 --- /dev/null +++ b/.github/workflows/ci-quality-gates.yml @@ -0,0 +1,113 @@ +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: 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 --no-daemon --stacktrace + + sample-off: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + 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 + + # 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 + - 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 + 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 }} + run: | + set -euo pipefail + for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_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." diff --git a/.github/workflows/dependency-vulnerability.yml b/.github/workflows/dependency-vulnerability.yml new file mode 100644 index 0000000..d91d419 --- /dev/null +++ b/.github/workflows/dependency-vulnerability.yml @@ -0,0 +1,185 @@ +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 + - 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." diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000..2d7516d --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,43 @@ +name: link-check + +on: + pull_request: + paths: + - "README.md" + - "src/README.md" + - "docs/**/*.md" + - ".github/**/*.md" + - ".github/workflows/link-check.yml" + push: + branches: ["main"] + paths: + - "README.md" + - "src/README.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 + 'docs/**/*.md' + '.github/**/*.md' + fail: true diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..74096e8 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +java temurin-21.0.11+10 diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000..a5cbbfe --- /dev/null +++ b/.trivyignore.yaml @@ -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: [] diff --git a/AGENTS.md b/AGENTS.md index 2e0c302..0b72dcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,19 @@ 동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다. -HARD-STOP 8개 항목의 SSOT 는 `.agents/plugins/ca-superpowers/rules/clean-architecture.md` §HARD-STOP 이다. 이 문서는 목록 사본을 유지하지 않는다 — 불일치 시 SSOT 파일이 우선한다. (항상 로드되는 요약 사본은 root `CLAUDE.md` Prime Directive 에 있다.) +다음 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 @@ -35,17 +47,19 @@ HARD-STOP 8개 항목의 SSOT 는 `.agents/plugins/ca-superpowers/rules/clean-ar - `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다. - `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다. -## Harness 정책 SSOT +## Gradle 정책 권위 -- `.harness/manifest.yaml`: Java 21 / Spring Boot 4.0.0 프로젝트 identity와 task packet 진입점 -- `.harness/project/modules.yaml`: 19개 leaf의 ID, 소스 경로, Gradle path, 허용 edge, focused command -- `.harness/core/risk-policy.yaml`: change-surface 기반 risk 분류 -- `.harness/core/evidence-policy.yaml`: risk별 evidence/review profile -- `.harness/core/review-policy.yaml`: orchestration, option/counterargument, human-only commit 정책 -- `.harness/core/report-policy.yaml`: concise/durable report와 citation self-grep 정책 +- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로, + Gradle path, 허용 production project dependency edge +- `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 :test --console=plain` 형태로 파생한다. 파일 수만으로 위험을 판단하지 않고, +변경한 경계와 런타임·보안·데이터 영향에 맞춰 설계·리뷰·검증 강도를 높인다. -작업 시작 시 task packet을 한 번 resolve하고 stable packet/rule hash를 재사용한다. overlay나 관련 -hash가 바뀔 때만 다시 resolve하거나 rule 전문을 재정독한다. 파일 수는 risk 분류 기준이 아니다. commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다. ## LLM Wiki 캡처 워크플로우 @@ -58,7 +72,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit /home/donghyeon/workspace/ai-tool/llm-wiki-private/ ``` -에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다. ca-tmpl 내부의 상세 실행 규칙은 `.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`를 따른다. +에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다. 필수 순서: @@ -87,9 +101,10 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit ## 모듈 책임 -19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 의존성, focused test 명령은 -`.harness/project/modules.yaml`이 SSOT다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 -정의한다. 작업 파일에서는 가장 가까운 `src/**/CLAUDE.md`를 함께 읽는다. +19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성은 +`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 예외. @@ -116,7 +131,7 @@ runtime modules -> shared-contract sample-portfolio -> registered runtime leaves (fixture consumer only) ``` -개별 edge는 `.harness/project/modules.yaml`의 `allowed_dependencies`가 유일한 목록이다. +개별 edge는 `src/config/architecture/modules.json`의 `allowed_dependencies`가 유일한 목록이다. Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다. ## 기능 개발 프로토콜 @@ -157,8 +172,7 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억 ```bash cd src -python3 ../.harness/validators/resolve_task.py -# resolved packet의 focused_commands를 실행 +./gradlew :test --console=plain ./gradlew test ./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle) ./gradlew verifyCleanArchitectureDependencies @@ -166,8 +180,8 @@ python3 ../.harness/validators/resolve_task.py ./gradlew verifyEnvKeys ``` -19개 leaf의 정확한 focused test 명령은 `.harness/project/modules.yaml`과 resolved task packet을 -따른다. root 문서에 별도 명령 목록을 복제하지 않는다. +소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test +명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다. ## 설정과 런타임 diff --git a/CLAUDE.md b/CLAUDE.md index e97c4df..3152dbf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,49 +4,46 @@ Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture templ ## Prime Directive -Preserve architecture before optimizing for speed. The HARD-STOP SSOT is -`.agents/plugins/ca-superpowers/rules/clean-architecture.md`; its eight current stop conditions are: +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 `.harness/project/modules.yaml` and Gradle verification. +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 corpus conclusion is made without evidence appropriate to the selected evidence profile. +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 and the SSOT differ, the SSOT wins. +If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must be resynchronized. -## Harness policy authorities +## Gradle policy authorities -- Project manifest and stack: `.harness/manifest.yaml` -- Leaf modules, dependency edges, and focused commands: `.harness/project/modules.yaml` -- Risk classification: `.harness/core/risk-policy.yaml` -- Evidence selection: `.harness/core/evidence-policy.yaml` -- Orchestration and advisory depth: `.harness/core/review-policy.yaml` -- Durable/concise reporting: `.harness/core/report-policy.yaml` -- Physical parity check: `.harness/validators/validate_policy_parity.py` - -Resolve a task packet once and reuse its stable task-packet hash and relevant rule hashes. Rerun -resolution or reread a full rule only when the task overlay, packet hash, or rule hash changes. +- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source + paths, Gradle paths, and allowed production project dependency edges. +- `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. -## Risk-based orchestration +## Proportional workflow -- Low risk: implement inline or in a focused lane and run the focused check. -- Medium risk: use `ca-implementer`; select proportional review based on affected boundaries and - evidence needs. -- High risk: use `ca-implementer`, then the full chain after a human commit: - `ca-architect-sentinel` → `ca-spec-reviewer` → `ca-quality-reviewer` → `gradle-runner`. +- 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 task flags, not file count. +Risk comes from change surface and runtime, security, data, or public-contract impact, not file +count. ## Module families -`.harness/project/modules.yaml` owns the complete 19-leaf list. Root guidance summarizes families; -the nearest `src/**/CLAUDE.md` owns local rules. +`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 | | --- | --- | --- | @@ -60,7 +57,8 @@ the nearest `src/**/CLAUDE.md` owns local rules. | `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. -Read it from `.harness/project/modules.yaml` or from the resolved task packet. +Read its `gradle_path` and `allowed_dependencies` from +`src/config/architecture/modules.json`; derive the focused test from that Gradle path. ## Layer workflow @@ -88,7 +86,9 @@ or writable scope, stop and request context rather than expanding silently. - identifier: pure deterministic unit tests. - bootstrap/settings: binding, validation, wiring, and architecture tests. -From `src/`, run the exact focused command emitted in the task packet. Architecture-wide commands: +From `src/`, read the owning leaf's `gradle_path` from +`config/architecture/modules.json` and run `./gradlew :test --console=plain`. +Architecture-wide commands: ```bash ./gradlew verifyCleanArchitectureDependencies --console=plain @@ -114,6 +114,6 @@ or the regulated profile. Otherwise a concise result is allowed. ## LLM Wiki capture -For non-trivial implementation or workflow changes, follow -`.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`. If the controller explicitly excludes -wiki writes for a dispatched task, report the handoff instead of writing outside scope. +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. diff --git a/README.md b/README.md index a697473..fd90f65 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ family 수준의 책임은 다음과 같습니다. | `app-bootstrap` | Spring Boot entrypoint와 composition root | | `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 | -정확한 19개 leaf 목록, 각 leaf의 Gradle path·허용 의존 edge·focused test 명령은 [.harness/project/modules.yaml](.harness/project/modules.yaml)이 SSOT입니다. root 문서나 기억에서 개별 leaf edge를 추론하지 않습니다. +정확한 19개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는 +[src/config/architecture/modules.json](src/config/architecture/modules.json)이 SSOT입니다. focused +test는 해당 Gradle path에서 `./gradlew :test --console=plain` 형태로 파생하며, root +문서나 기억에서 개별 leaf edge를 추론하지 않습니다. ## 퀵스타트 @@ -65,7 +68,7 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g' ``` - 레지스트리 `package_roots`와 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다. + 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다. 3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다. 4. 모듈 이름과 경계는 그대로 유지합니다. @@ -82,7 +85,8 @@ cd src ## 아키텍처 규칙과 검증 -애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 SSOT는 [clean-architecture.md](.agents/plugins/ca-superpowers/rules/clean-architecture.md)입니다. +애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 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를 반환하지 않습니다. @@ -90,7 +94,9 @@ cd src - 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다. - 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다. -이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을, `verifyCleanArchitectureDependencies` 게이트가 Gradle 프로젝트 의존성을 검사하며, 둘 다 registry의 허용 edge를 읽습니다. +이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을, +`verifyCleanArchitectureDependencies` 게이트가 JSON registry의 허용 Gradle project edge를 +검사합니다. ```bash cd src @@ -102,7 +108,9 @@ cd src ## 더 알아보기 - 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md) -- 모듈 레지스트리(19개 leaf SSOT): [.harness/project/modules.yaml](.harness/project/modules.yaml) +- 모듈 레지스트리(19개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json) - 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md) -- 빌드·릴리스 공급망 파이프라인: [build-release-supply-chain.yml](.github/workflows/build-release-supply-chain.yml) +- 빌드·릴리스 공급망 파이프라인은 현재 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) diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 3b0a8fe..6384940 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -540,6 +540,18 @@ env_keys: compatibility_impact: behavior-change required_test: outbound-contract:global-call-timeout-bounded + - name: APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS + type: int + default: 128 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: httpclient-production-capability + validation: int_range_1_10000 + compatibility_impact: additive + required_test: outbound-contract:maximum-in-flight-calls-bounded + - name: APP_OUTBOUND_HTTP_RETRY_ENABLED # source: feature-outbound-http-client-baseline 2026-05-22 # "retry 기본값은 disabled이며, 활성화 시 retryable registry error와 low-cardinality retry metric이 필수" @@ -1230,6 +1242,18 @@ env_keys: compatibility_impact: behavior-change required_test: adapter-contract:redis-disabled-default + - name: APP_CACHE_REDIS_CLIENT_MODE + type: enum + default: managed + allowed_values: [managed, external] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: enum_strict + compatibility_impact: additive + required_test: adapter-contract:redis-client-mode-explicit + - name: APP_CACHE_REDIS_HOST # source: feature-cache-consistency-contract — Redis adapter (활성화 시 endpoint 필요) type: string @@ -1256,6 +1280,102 @@ env_keys: compatibility_impact: behavior-change required_test: cache-contract:redis-port-bound + - name: APP_CACHE_REDIS_PASSWORD + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: must_not_be_local_dev_sentinel_in_prod + compatibility_impact: additive + required_test: secrets-contract:redis-password-no-leak + + - name: APP_CACHE_REDIS_KEY_HMAC_SECRET + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: base64_min_32_bytes_when_redis_enabled + compatibility_impact: additive + required_test: cache-contract:redis-hmac-secret-bounded + + - name: APP_CACHE_REDIS_COMMAND_TIMEOUT + type: duration + default: 2s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: spring_duration_shorthand_non_zero_le_30s + compatibility_impact: additive + required_test: cache-contract:redis-command-timeout-bounded + + - name: APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS + type: int + default: 8 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: int_1_to_4096 + compatibility_impact: additive + required_test: cache-contract:redis-command-queue-bounded + + - name: APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES + type: int + default: 16777216 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: covers_maximum_value_and_le_268435456 + compatibility_impact: additive + required_test: cache-contract:redis-command-byte-admission-bounded + + - name: APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT + type: string + default: local + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: lowercase_slug + compatibility_impact: behavior-change + required_test: cache-contract:redis-namespace-environment-bound + + - name: APP_CACHE_REDIS_SEMANTIC_REGION + type: string + default: default + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: lowercase_slug + compatibility_impact: behavior-change + required_test: cache-contract:redis-semantic-region-bound + + - name: APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES + type: int + default: 1048576 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: codex-phase-a-ci-recovery + validation: int_1_to_16777216 + compatibility_impact: additive + required_test: cache-contract:redis-value-size-bounded + - name: APP_CACHE_DEFAULT_TTL # source: feature-cache-consistency-contract 2026-05-22 # "TTL | explicit per key family | no-cache for sensitive data | immortal cache forbidden" diff --git a/docs/registries/secrets-classification.yaml b/docs/registries/secrets-classification.yaml index a890281..99a55ef 100644 --- a/docs/registries/secrets-classification.yaml +++ b/docs/registries/secrets-classification.yaml @@ -89,6 +89,18 @@ secrets: compatibility_impact: breaking required_test: secrets-contract:redis-password-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_PRIVACY_PSEUDONYMIZATION_SALT # source: feature-data-retention-privacy-contract 2026-05-22 # "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일. diff --git a/docs/security/public-paths-snapshot.txt b/docs/security/public-paths-snapshot.txt new file mode 100644 index 0000000..8e59329 --- /dev/null +++ b/docs/security/public-paths-snapshot.txt @@ -0,0 +1,4 @@ +# feature-security-operational-baseline D5 — deny-by-default public path snapshot. +# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated. +# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange +/api/healthcheck diff --git a/docs/superpowers/plans/2026-07-20-harness-policy-engine.md b/docs/superpowers/plans/2026-07-20-harness-policy-engine.md index 55c3f2a..ab721f5 100644 --- a/docs/superpowers/plans/2026-07-20-harness-policy-engine.md +++ b/docs/superpowers/plans/2026-07-20-harness-policy-engine.md @@ -1,3 +1,7 @@ +> **SUPERSEDED — HISTORICAL PROVENANCE ONLY (2026-07-25):** The user-approved harness-free +> Mode B amendment supersedes this plan. Retain the body as historical provenance; it is not +> executable instruction. + # Harness Policy Engine Implementation Plan > **Spec:** `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md` diff --git a/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting-harness-free.md b/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting-harness-free.md new file mode 100644 index 0000000..0356a1e --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting-harness-free.md @@ -0,0 +1,144 @@ +# Application Outbox Failure Reporting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `application-core` framework/logging-free while preserving one safe structured ERROR +after each confirmed outbox FAILED/DEAD transition. + +**Architecture:** The application owns a narrow typed reporting port and safe report value. +Messaging renders the report through SLF4J, and bootstrap only injects it. Transition state remains +authoritative; reporter failures are non-authoritative and contained. + +**Tech Stack:** Java 21 records, JUnit Jupiter, AssertJ, Spring Boot 4 configuration, SLF4J 2 fluent +logging, Logback capture tests, ArchUnit, Gradle Groovy DSL, dependency locking. + +--- + +### Task 1: Safe Application Report Contract + +**Files:** + +- Create: `src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java` + +- [ ] Write factory, invariant, and reflection-whitelist tests for the exact eight record components. +- [ ] Run `./gradlew :application-core:test --tests '*OutboxRelayFailureReportTest' --console=plain` + and record the expected missing-type RED. +- [ ] Implement the immutable record, exact invariants, factories, and functional port. +- [ ] Re-run the focused value test and record GREEN. + +### Task 2: Relay Reporting Behavior + +**Files:** + +- Modify: `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` +- Modify: `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java` +- Modify direct test constructor sites under + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/` + +- [ ] Add recording/throwing reporters and tests for exact FAILED/DEAD reports, all no-report paths, + transition failure propagation, and reporter-failure continuation. +- [ ] Run the relay test and record constructor/behavior RED. +- [ ] Inject the reporter after the publish port, remove SLF4J, report only after successful + transition, and contain reporter `RuntimeException`. +- [ ] Update test-only direct constructors with explicit lambdas and re-run relay tests GREEN. + +### Task 3: Structured Messaging Adapter and Publish-Adapter Deduplication + +**Files:** + +- Create: + `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java` +- Create: + `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java` +- Modify: + `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- Modify: + `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` + +- [ ] Write Logback capture tests for exact ERROR count, fixed fields, throwable, retry-only time, + unsafe-data absence, internal logging failure containment, and the adapter contract that + `report(null)` never throws. +- [ ] Run + `./gradlew :adapter:outbound:messaging:test --tests '*Slf4jOutboxRelayFailureReportAdapterTest' --console=plain` + and record missing-type RED. +- [ ] Implement the SLF4J 2 fluent adapter and re-run GREEN. +- [ ] Replace outbox publish WARN expectations with no-log and propagation expectations; run RED. +- [ ] Remove `FailOpenDependencyLogger` from the outbox adapter and re-run its tests GREEN, leaving + `OutboundMessagePublisher` unchanged. + +### Task 4: Unconditional Reporter Wiring + +**Files:** + +- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java` +- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` + +- [ ] Add disabled and active context assertions for exactly one structured reporter bean. +- [ ] Run `OptionalAdapterBeanGatingTest` and record RED. +- [ ] Add the unconditional messaging reporter bean, use `disabled` for blank broker, update outbox + publish adapter construction, and inject the port through bootstrap. +- [ ] Re-run the gating and outbox configuration tests GREEN. + +### Task 5: Application Dependency Purity + +**Files:** + +- Modify: `src/build.gradle` +- Modify: `src/application-core/build.gradle` +- Mechanically regenerate only: `src/application-core/gradle.lockfile` + +- [ ] Add `verifyApplicationCoreDependencyPurity`, wire it into `:application-core:check`, and run it + against the current starter declaration to record RED. +- [ ] Give `application-core` only JUnit Jupiter and AssertJ test dependencies while retaining the + shared Boot test dependencies for every other leaf. +- [ ] Remove the application Spring Boot starter and re-run the purity task GREEN. +- [ ] Run + `./gradlew :application-core:resolveAndLockAll --write-locks --console=plain`; confirm no other + lockfile changes. +- [ ] Run application lock verification, tests, and compile/test runtime dependency reports. + +### Task 6: Non-Vacuous Diagnostic Architecture Rule + +**Files:** + +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Create: + `src/app-bootstrap/src/test/java/dev/caskeleton/application/architecture/violations/ApplicationDiagnosticFrameworkViolation.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java` + +- [ ] Add the violation fixture inside the exact `dev.caskeleton.application..` rule scope and its + mutation assertion; run it before the rule to record RED. +- [ ] Add `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`, scoped exactly to + `dev.caskeleton.application..`, for SLF4J, JUL, Logback, Log4j, and Micrometer. +- [ ] Run the mutation test and production `CleanArchitectureTest` GREEN. + +### Task 7: Documentation and Verification + +**Files:** + +- Modify: `src/application-core/CLAUDE.md` +- Modify: `src/application-core/README.md` +- Modify: `src/adapter/outbound/messaging/CLAUDE.md` +- Modify: `src/adapter/outbound/messaging/README.md` +- Modify relevant wiring guidance in `src/app-bootstrap/README.md` + +- [ ] Document the framework-free application contract, typed report semantics, messaging ownership, + duplicate-log rule, and bootstrap wiring-only role. +- [ ] Run focused application, messaging, gating, architecture mutation, production architecture, + and available outbox integration tests. +- [ ] Run `verifyCleanArchitectureDependencies`, dependency evidence reports, and `check`. +- [ ] Run required safety greps, `git diff --check`, and `git status --short`; report any skip or + remaining risk. +- [ ] Hand the exact LLM Wiki capture responsibility and evidence back to the top-level controller; + do not write the vault from this dispatched scope. + +No step authorizes staging, committing, amending, pushing, public-path changes, CI changes, module +registry changes, or `.harness` changes. diff --git a/docs/superpowers/plans/2026-07-25-harness-free-mode-b-amendment.md b/docs/superpowers/plans/2026-07-25-harness-free-mode-b-amendment.md new file mode 100644 index 0000000..d59996e --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-harness-free-mode-b-amendment.md @@ -0,0 +1,99 @@ +# Harness-Free Mode B Amendment Implementation Plan + +> **For agentic workers:** Execute this plan task-by-task with +> `superpowers:executing-plans`; use `superpowers:test-driven-development` for the build behavior +> change and `superpowers:verification-before-completion` before reporting results. + +**Goal:** Restore Gradle bootstrap and Clean Architecture dependency enforcement without recreating +the absent development harness. + +**Architecture:** One strict JSON registry under `src/config/architecture/` owns all 19 leaf +identities, paths, and allowed production project edges. Gradle settings validate and include the +registry fail-closed; the root dependency verification task reads the same file and checks actual +production project dependencies against it. + +**Tech Stack:** Gradle Groovy DSL, Groovy `JsonSlurper`, strict JSON, Java 21. + +**Working policy:** Human-only git handling. Do not stage, commit, amend, push, or create a PR. + +--- + +### Task 1: Capture the broken bootstrap + +**Files:** + +- Read: `src/settings.gradle` + +- [x] Run `cd src && ./gradlew help --console=plain`. +- [x] Confirm exit 1 is caused by the missing `.harness/project/modules.yaml`, not dependency + resolution or an unrelated build failure. + +### Task 2: Add the Gradle-owned registry + +**Files:** + +- Create: `src/config/architecture/modules.json` +- Read: each of the 19 leaf-module `build.gradle` files + +- [x] Record exactly 19 unique module IDs, Gradle paths, and repository-relative source paths. +- [x] Set `allowed_dependencies` from each leaf's current `api`, `implementation`, `compileOnly`, + and `runtimeOnly` project dependencies. +- [x] Exclude test/fixture configurations from production policy and keep `sample-portfolio` a + fixture consumer that no production leaf may depend on. +- [x] Parse the file with Python's strict JSON parser and compare its edges with the checked-in + leaf build declarations. + +### Task 3: Restore Gradle bootstrap and dependency enforcement + +**Files:** + +- Modify: `src/settings.gradle` +- Modify: `src/build.gradle` + +- [x] Make settings load only `config/architecture/modules.json`. +- [x] Fail closed on a missing registry, wrong root/module/field types, empty values, duplicate + identities or paths, unsafe path shapes, unknown/self dependencies, count drift, or missing + source directories. +- [x] Include every registered Gradle path and map it to its repository-root-relative source + directory. +- [x] Make `verifyCleanArchitectureDependencies` read the same registry without a second module + list. +- [x] Preserve all-leaf coverage and forbidden-edge checks, explicitly reject a production edge + to `sample-portfolio`, and replace stale error wording with actionable registry guidance. + +### Task 4: Align active repository guidance + +**Files:** + +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` +- Modify: `README.md` +- Modify: `src/README.md` +- Modify: all 19 nearest leaf-module `CLAUDE.md` files that name the old registry +- Annotate as superseded: the 2026-07-20 harness design and plan + +- [x] Point active topology and allowed-edge guidance to + `src/config/architecture/modules.json`. +- [x] State that focused commands are derived from the owning Gradle path rather than a task + packet. +- [x] Keep all eight local HARD-STOP meanings, architecture boundaries, human-only git policy, + verification discipline, and LLM Wiki capture requirements. +- [x] Make the earlier harness documents explicit historical provenance rather than active + reconstruction instructions. + +### Task 5: Verify from a fresh Gradle invocation + +**Files:** + +- Verify: all changed files + +- [ ] Run `cd src && ./gradlew help --console=plain`. +- [ ] Run `cd src && ./gradlew projects --console=plain`. +- [ ] Run `cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain`. +- [ ] Run a deterministic strict-JSON script proving exactly 19 unique IDs/Gradle paths and + existing source directories. +- [ ] Run a deterministic comparison between registry edges and leaf production project + dependencies. +- [ ] Run `git diff --check` and `git status --short`. +- [ ] Report exact exits, any unavailable checks, LLM Wiki capture outcome, and remaining risks + without claiming the broader Phase A/refactor is complete. diff --git a/docs/superpowers/plans/2026-07-25-harness-free-quality-security-ci.md b/docs/superpowers/plans/2026-07-25-harness-free-quality-security-ci.md new file mode 100644 index 0000000..b66f13b --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-harness-free-quality-security-ci.md @@ -0,0 +1,117 @@ +# Harness-Free Quality and Security CI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this +> plan task-by-task, `superpowers:test-driven-development` for executable drift controls, and +> `superpowers:verification-before-completion` before reporting. Git remains human-only: do not +> stage, commit, amend, or push. + +**Goal:** Reconstruct a harness-free, repository-internal quality and dependency-security CI +control plane that is truthful to the current Gradle build and `main` branch. + +**Architecture:** Canonical workflows live only under `.github/workflows`. A small YAML gate matrix +maps current controls to real Gradle tasks/plugins/tests and workflow jobs, while a portable Bash +verifier rejects drift; vulnerability policy is enforced by a platform-neutral Trivy filesystem +job with guarded GitHub-only complements. + +**Tech Stack:** GitHub Actions-compatible YAML, Bash, Gradle 9 Groovy DSL, Java/Temurin 21, Trivy, +jq, lychee. + +--- + +### Task 1: Capture missing-control RED + +**Files:** + +- Verify absent: `.trivyignore.yaml` +- Verify absent: `.github/ci-gate-matrix.yml` +- Verify absent: `.github/scripts/verify-gate-matrix.sh` + +- [ ] Run `cd src && ./gradlew verifyTrivyignore --console=plain`. +- [ ] Confirm the failure names the missing repository-root `.trivyignore.yaml`. +- [ ] Confirm the matrix, verifier, and canonical workflows are absent. + +### Task 2: Add repository baselines + +**Files:** + +- Create: `.tool-versions` +- Create: `.gitattributes` +- Create: `.trivyignore.yaml` + +- [ ] Pin `java temurin-21.0.11+10`, matching candidate evidence and the local Gradle launcher JDK. +- [ ] Normalize source, YAML, Markdown, Gradle, and shell text to LF; keep `gradlew.bat` CRLF and + mark common binary formats `-text`. +- [ ] Add the four structured empty Trivy sections with suppression governance comments. +- [ ] Run `cd src && ./gradlew verifyTrivyignore --console=plain` and expect zero suppressions + validated. + +### Task 3: Add quality governance and drift verification + +**Files:** + +- Create: `.github/CODEOWNERS` +- Create: `.github/pull_request_template.md` +- Create: `.github/ci-gate-matrix.yml` +- Create: `.github/scripts/verify-gate-matrix.sh` +- Create: `.github/workflows/ci-quality-gates.yml` +- Create: `.github/workflows/link-check.yml` + +- [ ] Record only current Gradle/task/test/job mechanisms in the matrix. +- [ ] Implement repository-root-safe matrix parsing with schema, uniqueness, task/plugin/test, and + workflow-job checks. +- [x] Before Java/Gradle, fail unless `docs/security/public-paths-snapshot.txt` is committed and + non-empty; do not let the Gradle task create a first-run CI baseline. +- [ ] Have a human track and commit the canonical snapshot; agents do not stage or commit, and CI's + `git ls-files` precondition rejects an untracked worktree file. +- [ ] Add required `quality-gates`, `sample-off`, and `gate-matrix-lint` jobs plus the advisory + quarantine job. +- [ ] Make `release-gate` depend exactly on the three required jobs and fail unless all succeeded. +- [ ] Add path-scoped link checking for PR and `main` push. +- [ ] Pin every workflow `uses:` reference to a verified full commit SHA and retain its immutable + release label in an inline comment. +- [ ] Run Bash syntax and gate-matrix checks. + +### Task 4: Add dependency-vulnerability controls + +**Files:** + +- Create: `.github/dependency-review-config.yml` +- Create: `.github/dependency-vulnerability-policy.md` +- Create: `.github/scripts/install-jq.sh` +- Create: `.github/workflows/dependency-vulnerability.yml` + +- [ ] Configure PR dependency review to block new High/Critical runtime vulnerabilities and + forbidden strong/network-copyleft licenses without posting PR summary comments. +- [ ] Document High/Critical blocking, Medium/Low advisory, KEV fail-closed handling, suppression + review, GitHub/Gitea differences, egress, and mirror requirements. +- [ ] Install checksum-pinned jq and version-pinned Trivy under `${RUNNER_TEMP}`, adding them through + `${GITHUB_PATH}` without privileged writes. +- [ ] Guard GitHub-only review/submission and keep `trivy-fs` platform-neutral on all required + triggers. +- [ ] Pass `--ignorefile .trivyignore.yaml` to every Trivy invocation. +- [ ] Reject KEV catalogs with blank metadata, non-positive/non-integral or mismatched counts, + empty vulnerability arrays, invalid CVE identifiers, or duplicate identifiers before + intersection. +- [ ] Reject malformed or empty Trivy JSON before extracting candidate vulnerability identifiers. + +### Task 5: Verify the reconstructed slice + +**Files:** + +- Verify: all files created by this plan + +- [ ] Parse strict policy/matrix YAML with an available parser and document GitHub `on` parser + limitations if applicable. +- [ ] Prove only `main` is an active branch trigger and no active `master` remains. +- [ ] Prove every Trivy scan consumes the root ignore file. +- [ ] Prove the release fan-in is exact and excludes quarantine. +- [x] Prove the missing/empty/untracked snapshot precondition exits non-zero; the canonical + `/api/healthcheck` snapshot now exists in the worktree but still requires a human commit. +- [ ] Exercise the KEV predicate with empty/malformed/count/CVE/duplicate failures and a valid + synthetic catalog. +- [ ] Exercise the Trivy JSON predicate with malformed Results/Vulnerabilities/IDs and a realistic + valid Results array. +- [ ] Prove no harness call or `.gitea/workflows` shadow was introduced. +- [ ] Run `git diff --check` and `git status --short`. +- [ ] Capture the work in the required LLM Wiki branch note, including evidence and external + blockers, without claiming server Actions or full Phase A completion. diff --git a/docs/superpowers/plans/2026-07-25-module-gradle-hygiene-harness-free.md b/docs/superpowers/plans/2026-07-25-module-gradle-hygiene-harness-free.md new file mode 100644 index 0000000..7e6b549 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-module-gradle-hygiene-harness-free.md @@ -0,0 +1,103 @@ +# Harness-Free Module and Gradle Hygiene Implementation Plan + +**Goal:** Apply the approved 19-leaf dependency and boundary cleanup without `.harness`. + +**Spec:** `docs/superpowers/specs/2026-07-25-module-gradle-hygiene-harness-free-design.md` + +**Policy:** TDD for behavior/boundary changes; focused proof before dependency removal; human-only +Git operations. + +## Task 1: Lock Phase B and characterize the Phase C baseline + +- [ ] Confirm the Phase B focused tests, dependency-purity gate, spec review, and quality review + are green. +- [ ] Record the current 19-leaf registry and affected lockfiles. +- [ ] Run the existing OpenAPI runtime tests before changing springdoc. + +## Task 2: Isolate pure-core tests + +- [ ] Change the root test convention so `domain-core`, `application-core`, and + `shared-contract` receive only JUnit Jupiter, AssertJ, and the platform launcher. +- [ ] Run the three core test suites and dependency reports. +- [ ] Regenerate only their affected locks and prove no Spring coordinate remains on their test + runtime classpaths. + +## Task 3: Prune core/inbound declarations and align Boot 4 + +- [ ] Before editing, run and record each affected leaf's `compileJava`, `compileTestJava`, `test`, + runtime dependency report, and relevant dependency insight. +- [ ] Remove the approved unused project edges from application and inbound leaves. +- [ ] Upgrade springdoc to `3.0.0`. +- [ ] Remove unused GraphQL/WebSocket Jackson 2 declarations and unused gRPC direct declarations. +- [ ] Characterize `jackson-databind-nullable` with dependency insight and focused + present/null/undefined Jackson 3 tests; exclude its Jackson 2 transitive dependency only if the + tests and real-server OpenAPI contract remain green. +- [ ] Run each affected leaf test plus the two real-server `/v3/api-docs` tests. +- [ ] Update the OpenAPI snapshot only if the generated public contract is semantically unchanged. + +## Task 4: Prune outbound declarations + +- [ ] Before editing, run and record each affected leaf's `compileJava`, `compileTestJava`, `test`, + runtime dependency report, and relevant dependency insight. +- [ ] Apply the approved support/cache/httpclient/identifier/messaging/notification project-edge + removals. +- [ ] Remove Groovy/Spock only from leaves with no Groovy tests. +- [ ] Narrow fileserver/objectstorage from the broad Boot starter to autoconfigure plus SLF4J API. +- [ ] Remove the JPA domain edge and remove explicit Flyway core only if focused compile/test proves + it is redundant. +- [ ] Run affected compile/tests before and after each dependency group. + +## Task 5: Enforce configuration-processor parity + +- [ ] Add a failing verification fixture or temporary mutation proving the exact + `@ConfigurationProperties(` parity check detects missing and extra processors. +- [ ] Register `verifyConfigurationPropertiesProcessor` from the JSON registry and wire it into + leaf `check`. +- [ ] Add processors to settings-owning leaves and remove the unused GraphQL processor. +- [ ] Run the new gate and affected settings tests. + +## Task 6: Remove the Mongo example domain + +- [ ] Add tests for disabled mode, enable-flag binding, and enabled infrastructure with a mock + `MongoClient`. +- [ ] Delete all production/test `Example*` types and remove the fixed example bean/repository + scanning. +- [ ] Remove obsolete project and Testcontainers dependencies. +- [ ] Run the Mongo tests and an `rg` assertion that production contains no `Example*`. + +## Task 7: Invert sample correlation access + +- [ ] Add framework-free `CorrelationIdPort` contract tests/fakes. +- [ ] Add and test the inbound web MDC implementation. +- [ ] Change the two sample application collaborators to use the port while retaining event-id + fallback behavior. +- [ ] Add an architecture assertion that sample application source has no SLF4J dependency. +- [ ] Run application, web, sample outbox/poster, and architecture focused tests. + +## Task 8: Clean generated state and composition documentation + +- [ ] Delete tracked `src/sample-portfolio/.jqwik-database` and ignore future files. +- [ ] Correct app-bootstrap “every module” wording and document default versus opt-in runtime + composition. +- [ ] Preserve the existing default runtime dependency set. + +## Task 9: Locks, full verification, and review + +- [ ] Regenerate strict lockfiles only with each affected leaf's + `:leaf-path:resolveAndLockAll --write-locks`; do not run the root all-leaf writer. +- [ ] Run all commands in the design verification section. +- [ ] Run `git diff --check` and inspect the complete unstaged/untracked status. +- [ ] Request spec and code-quality review; fix all actionable findings. +- [ ] Update the mandated LLM Wiki raw branch note and derived raw notes, or record the exact + missing-vault blocker. + +## Final review hardening + +- [x] Pin the Springdoc 3 `ApiError.details` widening with a real-server RED test. +- [x] Add a web-owned OpenAPI customizer, import it in both real-server test applications, and + restore the committed `type: object` snapshot without adding Swagger to `shared-contract`. +- [x] Reproduce starter-driven Mongo activation through an actual `@EnableAutoConfiguration` + context in both default and explicit-false modes. +- [x] Register a module-level Boot 4 `AutoConfigurationImportFilter` that blocks Mongo + auto-configuration until the module enable flag is true. +- [x] Re-run affected formatting, locks, focused tests, and all design verification commands. diff --git a/docs/superpowers/plans/2026-07-28-fileserver-durable-recovery.md b/docs/superpowers/plans/2026-07-28-fileserver-durable-recovery.md new file mode 100644 index 0000000..5b1ed10 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-fileserver-durable-recovery.md @@ -0,0 +1,66 @@ +# Fileserver Durable Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this +> plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Repository policy is +> human-only, so no step stages or commits changes. + +**Goal:** Make the local publication provider restart-safe for completed and sealed operations +without re-running the row producer. + +**Architecture:** Keep the application port unchanged. The adapter owns a private operation journal +under `.ca-fileserver/operations`, writes records through forced temp files and atomic rename, and +uses a deterministic request fingerprint. A retry restores a verified terminal receipt or resumes a +sealed staged artifact; disagreement is a conflict or indeterminate outcome, never an overwrite. + +**Tech Stack:** Java 21 NIO, JUnit 5, AssertJ, existing Gradle quality gates. + +--- + +### Task 1: Define deterministic journal records and request fingerprints + +**Files:** +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalRecord.java` +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java` +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java` +- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java` + +- [x] Write a failing test proving stable request fingerprints and different fingerprints for + source/schema changes. +- [x] Write a failing test proving journal round-trip and rejection of corrupt/newer records. +- [x] Run + `./gradlew :adapter:outbound:fileserver:test --tests '*LocalPublicationJournalTest' --console=plain` + and confirm the missing types fail compilation. +- [x] Implement a bounded flat JSON codec with schema version, state, fingerprint, locator token, + checksum/counts and receipt snapshot fields. It must reject duplicate/unknown keys and never + serialize absolute paths or row data. +- [x] Run the focused test and confirm GREEN. + +### Task 2: Add forced atomic journal persistence and recovery + +**Files:** +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java` +- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java` +- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationRecoveryTest.java` + +- [x] Write a failing test where a completed operation is retried with a producer that throws; the + original receipt must be returned and the producer must remain uncalled. +- [x] Write a failing test that reconstructs a new adapter over a sealed journal plus staged bytes + and resumes publication without calling the producer. +- [x] Write a failing test proving the same operation ID with a different request is a conflict and + a digest mismatch is indeterminate. +- [x] Run the recovery test and confirm RED. +- [x] Persist `WRITING`, `SEALED`, and `PUBLISHED` records with temp + force + atomic move. Verify + the target size and SHA-256 before terminal reconstruction. +- [x] Run all Fileserver tests and confirm GREEN. + +### Task 3: Report the exact readiness boundary + +**Files:** +- Modify: `src/adapter/outbound/fileserver/README.md` +- Modify: `src/adapter/outbound/fileserver/CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` + +- [x] Mark single-node local restart recovery as implemented. +- [x] Keep multi-node fencing, bounded background reaper, SFTP, NFS and HA evidence explicitly + unimplemented. +- [x] Run `./gradlew :adapter:outbound:fileserver:check --console=plain`. diff --git a/docs/superpowers/plans/2026-07-28-fileserver-production-capability-foundation.md b/docs/superpowers/plans/2026-07-28-fileserver-production-capability-foundation.md new file mode 100644 index 0000000..6750df8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-fileserver-production-capability-foundation.md @@ -0,0 +1,236 @@ +# Fileserver Production Capability Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the list-materializing CSV demo boundary with the Phase 1 framework-free publication contract and a bounded, staged local CSV R1 provider without claiming crash-safe R2 guarantees. + +**Architecture:** `application-core` owns typed publication requests, rows, cells, producer/sink callbacks, opaque references, and receipts. `adapter:outbound:fileserver` owns CSV encoding, spreadsheet-formula mitigation, staging, digest/count limits, and local atomic publication. The legacy `FileExportPort` remains temporarily for compatibility and is explicitly documented as deprecated R0/R1 behavior. + +**Tech Stack:** Java 21, JUnit 5, AssertJ, Spring Boot configuration properties, JDK NIO filesystem and SHA-256. + +--- + +### Task 1: Add the framework-free publication contract + +**Files:** +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java` +- Test: `src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java` + +- [ ] **Step 1: Write the failing contract test** + +```java +@Test +void requestRejectsPathLikeLogicalNamesAndSchemaRejectsDuplicateColumns() { + assertThatThrownBy(() -> new LogicalFileName("../report.csv")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new ExportSchema( + "worklog-v1", + 1, + List.of( + new ExportSchema.Column( + "id", ExportSchema.CellType.INTEGER, false, + ExportSchema.FormulaPolicy.REJECT, 64), + new ExportSchema.Column( + "id", ExportSchema.CellType.TEXT, false, + ExportSchema.FormulaPolicy.MITIGATE, 128)))) + .isInstanceOf(IllegalArgumentException.class); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain` + +Expected: compilation failure because the `filepublication` contract does not exist. + +- [ ] **Step 3: Implement immutable validated values** + +The contract must expose this shape and no `Path`, `File`, stream, Spring, or provider type: + +```java +public interface FilePublicationPort { + FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer); +} + +@FunctionalInterface +public interface TabularRowProducer { + void produce(TabularRowSink sink); +} + +public interface TabularRowSink { + void write(TabularRow row); + void checkpoint(); +} +``` + +`TabularCell` is a sealed interface with nested records for text, integer, decimal, boolean, date, +instant, and null. `ExportSchema` owns ordered columns, cell type, nullability, formula policy, and +per-cell byte bounds. Records reject null/blank IDs, path separators in `LogicalFileName`, duplicate +column names, empty schemas, and non-positive limits. + +- [ ] **Step 4: Verify GREEN** + +Run: `cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain` + +Expected: PASS. + +### Task 2: Add streaming CSV encoding and staged local publication + +**Files:** +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java` +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java` +- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java` +- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java` + +- [ ] **Step 1: Write the failing streaming publication tests** + +```java +@Test +void publishesRowsThroughTheSinkAndReturnsAnOpaqueReceipt() { + AtomicInteger calls = new AtomicInteger(); + FilePublishReceipt receipt = + adapter.publish( + request(), + sink -> { + calls.incrementAndGet(); + sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd")))); + }); + + assertThat(calls).hasValue(1); + assertThat(receipt.reference().value()).doesNotContain(tempDir.toString()); + assertThat(Files.readString(publishedFile(receipt), UTF_8)).contains("1,'=cmd"); +} + +@Test +void abortsBeforeFinalPublicationWhenTheByteLimitIsExceeded() { + assertThatThrownBy( + () -> adapter.publish(request(), sink -> sink.write(oversizedRow()))) + .isInstanceOf(FilePublicationException.class); + assertThat(finalArtifacts()).isEmpty(); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain` + +Expected: compilation failure because the staged provider does not exist. + +- [ ] **Step 3: Implement the minimum staged provider** + +`LocalFilePublicationPolicy` validates a fixed destination ID, base directory, maximum rows, +maximum encoded bytes, and the only initial format profile `csv-rfc4180-v1`. + +`LocalFilePublicationAdapter` must: + +```text +validate request/schema before producer invocation +create a private .staging directory +exclusive-create an operation-scoped .part file +write header and each row directly through StreamingCsvEncoder +enforce schema/cell/row/byte limits at each sink call +prefix dangerous spreadsheet text with a single quote when policy is MITIGATE +compute SHA-256 and counts while writing +flush and FileChannel.force(true) +move staging to the final operation-scoped file with ATOMIC_MOVE +delete staging on pre-publish failure +return an opaque reference and never an absolute path +``` + +The first release is labelled local R1. Existing final artifacts cause a typed conflict; durable +operation journals, crash reconciliation, replace semantics, and SFTP/NFS remain unimplemented and +must not be advertised. + +- [ ] **Step 4: Verify GREEN** + +Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain` + +Expected: PASS. + +### Task 3: Add opt-in R1 composition and truthful documentation + +**Files:** +- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java` +- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java` +- Create: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java` +- Modify: `src/adapter/outbound/fileserver/README.md` +- Modify: `src/adapter/outbound/fileserver/CLAUDE.md` + +- [ ] **Step 1: Write the failing composition test** + +```java +@Test +void disabledConfigurationCreatesNoPublicationPort() { + contextRunner + .withUserConfiguration(FileExportConfig.class) + .run(context -> assertThat(context).doesNotHaveBean(FilePublicationPort.class)); +} + +@Test +void enabledConfigurationCreatesExactlyOneLocalR1PublicationPort() { + contextRunner + .withUserConfiguration(FileExportConfig.class) + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.destination-id=local-export", + "ca-skeleton.fileserver.base-directory=" + tempDir) + .run(context -> assertThat(context).hasSingleBean(FilePublicationPort.class)); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*FilePublicationConfigTest' --console=plain` + +Expected: FAIL because the new port is not composed. + +- [ ] **Step 3: Wire only the local R1 provider** + +Add validated destination ID, row limit, byte limit, and format-profile settings. Contribute +`FilePublicationPort` only when explicitly enabled. Keep `FileExportPort` as a deprecated compatibility +bean and document that it materializes caller rows and is not R2 evidence. + +- [ ] **Step 4: Verify module and architecture gates** + +Run: + +```bash +cd src +./gradlew :application-core:test :adapter:outbound:fileserver:check --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: all commands PASS. + +### Task 4: Record the unfinished R2 boundary + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` + +- [ ] **Step 1: Update implementation status without weakening completion criteria** + +Record Phase 0–1/local R1 foundation as implemented. Keep Phase 2 durable journal/reconciliation, +Phase 3 operations, Phase 4 SFTP, Phase 5 NFS/HA/bootstrap, and Phase 6 optional operations marked +unimplemented. The document must still say that local R1 is not Fileserver R2. + +- [ ] **Step 2: Verify documentation structure** + +Run: `rg -n 'R1|R2|구현 상태|미구현' docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` + +Expected: explicit R1 implementation and remaining R2 gaps are both present. diff --git a/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md b/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md new file mode 100644 index 0000000..ea227cf --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md @@ -0,0 +1,79 @@ +# HTTP Client Production Capability Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Establish the framework-free call-budget and typed operation/target foundation, then close +two proven safety defects in the legacy JDK provider without claiming Apache HC5, hard total +deadline, egress security, or R2 readiness. + +**Architecture:** `application-core` owns only a monotonic `CallBudget`. Product forks continue to +own feature-specific semantic ports. `adapter:outbound:httpclient` owns destination/operation IDs, +immutable operation descriptors, relative target construction, status/retry/body semantics, and +legacy provider fixes. The generic `OutboundHttpClient` remains a migration facade. + +**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical binding +composition, exact readiness tuple registry, Apache HC5 pool, active cancellation, TLS/DNS/proxy, +auth, codec, and real-network qualification remain unimplemented. + +--- + +### Task 1: Add a framework-free monotonic call budget + +**Files:** +- Create: `src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java` +- Test: `src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java` + +- [ ] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection. +- [ ] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types. +- [ ] Verify GREEN. + +### Task 2: Add typed operation catalog and safe target construction + +**Files:** +- Modify: `src/adapter/outbound/httpclient/build.gradle` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpDestinationId.java` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationId.java` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationDescriptor.java` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/target/FixedHttpDestination.java` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilder.java` +- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java` +- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilderTest.java` + +- [ ] Write RED tests for ID/uniqueness/cross-field operation invariants. +- [ ] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and + multi-segment variables. +- [ ] Implement closed immutable descriptors and one-pass path-segment encoding. +- [ ] Verify GREEN. + +### Task 3: Correct characterized legacy provider safety defects + +**Files:** +- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java` +- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java` +- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java` + +- [ ] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting. +- [ ] Make streaming validate status before exposing the body and discard error bodies. +- [ ] Put circuit breaker around each physical attempt and retry around the attempt loop. +- [ ] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets. +- [ ] Verify focused regressions and the full legacy test suite. + +### Task 4: Record exact readiness and verify + +**Files:** +- Modify: `src/adapter/outbound/httpclient/README.md` +- Modify: `src/adapter/outbound/httpclient/CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` + +- [ ] Mark the implemented foundation and fixed legacy defects. +- [ ] Keep total deadline/cancellation, canonical zero-binding composition, Apache pool, fixed + egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented. +- [ ] Run: + +```bash +cd src +./gradlew :application-core:check :adapter:outbound:httpclient:check --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` diff --git a/docs/superpowers/plans/2026-07-28-httpclient-total-deadline.md b/docs/superpowers/plans/2026-07-28-httpclient-total-deadline.md new file mode 100644 index 0000000..40e0d08 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-httpclient-total-deadline.md @@ -0,0 +1,59 @@ +# HTTP Client Total Deadline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this +> plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Repository policy is +> human-only, so no step stages or commits changes. + +**Goal:** Enforce `CallBudget` across the legacy HTTP logical call, including retry wait and blocking +I/O, and cancel the executing task when the absolute monotonic deadline wins. + +**Architecture:** Preserve the current migration facade but inject a bounded executor owned by each +client. Every call intersects the caller budget with the configured maximum, passes the same +absolute deadline to retry policy, waits through `Future.get(remaining)`, and cancels on timeout or +shutdown. This is R1 cancellation evidence, not Apache pool or hard-wire-cancellation R2 evidence. + +**Tech Stack:** Java 21 virtual-thread executor, Spring RestClient/JDK HttpClient, Resilience4j, +JUnit loopback HTTP server. + +--- + +### Task 1: Add deadline execution and explicit timeout vocabulary + +**Files:** +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallDeadlineExceededException.java` +- Create: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutor.java` +- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutorTest.java` + +- [x] Write failing tests proving an expired budget does not start work, a running task is + interrupted on expiry, and completion wins before the deadline. +- [x] Confirm RED. +- [x] Implement absolute monotonic remaining-time calculation, `Future.get`, cancellation and + exact exception mapping. +- [x] Confirm GREEN. + +### Task 2: Connect the budget to buffered and streaming calls + +**Files:** +- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java` +- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java` +- Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientDeadlineTest.java` + +- [x] Write a failing loopback test where response delay exceeds the budget and confirm bounded + return; record that JDK-provider server-side hard close is not proven by this lane. +- [x] Write a failing test proving a shorter caller budget wins and retry cannot start after expiry. +- [x] Confirm RED. +- [x] Add overloads accepting `CallBudget`; existing methods create a configured maximum budget. + Intersect budgets once and use the same deadline for retry and blocking execution. +- [x] Confirm GREEN and run the complete HTTP leaf tests. + +### Task 3: Record provider limits and verify + +**Files:** +- Modify: `src/adapter/outbound/httpclient/README.md` +- Modify: `src/adapter/outbound/httpclient/CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` + +- [x] Record active logical-call deadline/cancellation as implemented. +- [x] Keep explicit pool lease, Apache exact provider, DNS rebinding, TLS/auth/proxy and R2 hard + cancellation evidence unimplemented. +- [x] Run the HTTP leaf check and architecture/public-path gates. diff --git a/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md b/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md new file mode 100644 index 0000000..fd0347f --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md @@ -0,0 +1,93 @@ +# Redis Production Capability Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox syntax for tracking. + +**Goal:** Replace the adapter-only cache seam with a framework-free semantic cache contract, safe +physical key construction, and a versioned typed atomic-program foundation without claiming that a +real Redis runtime or any R2 capability is complete. + +**Architecture:** `application-core` owns provider-neutral cache outcomes and mutation intent. +`adapter:outbound:cache-redis` owns physical key construction, digesting, Lua resources, program +descriptors, and typed primitive facades. Existing legacy routing remains compatible while migration +is incremental. No Redis SDK, raw command, raw key, or Lua concept crosses into core. + +**Scope boundary:** This batch implements Phase 0 and selected Phase 1 foundations. Spring Data +Redis/Lettuce runtime, codec/envelope, real-service integration, topology, distributed rate limit, +idempotency, lease, session, and R2/R3 evidence remain separate implementation phases. + +--- + +### Task 1: Add the provider-neutral cache contract + +**Files:** +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordOutcome.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/CacheInvalidationOutcome.java` +- Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java` +- Test: `src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java` + +- [ ] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata. +- [ ] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`. +- [ ] Implement only framework-free values and ports. +- [ ] Verify GREEN. + +### Task 2: Add canonical Redis physical keys + +**Files:** +- Modify: `src/adapter/outbound/cache-redis/build.gradle` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyNamespace.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java` +- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java` + +- [ ] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and + absence of raw sensitive resource identifiers. +- [ ] Verify RED. +- [ ] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret + copies and length-prefixed component encoding. +- [ ] Verify GREEN. + +### Task 3: Add a typed, versioned atomic-program catalog + +**Files:** +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramId.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramDescriptor.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramCatalog.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramExecutor.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitives.java` +- Create: `src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-delete-v1.lua` +- Create: `src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-expire-v1.lua` +- Create: `src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/set-if-absent-with-ttl-v1.lua` +- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramCatalogTest.java` +- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitivesTest.java` + +- [ ] Write failing catalog and facade tests. +- [ ] Verify RED. +- [ ] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic + application-facing execution surface. +- [ ] Verify GREEN. + +### Task 4: Record exact readiness and verify + +**Files:** +- Modify: `src/adapter/outbound/cache-redis/README.md` +- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md` + +- [ ] Mark only contract/key/program foundation as implemented and all real runtime/capability + promotion as unimplemented. +- [ ] Run: + +```bash +cd src +./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +- [ ] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence + exist. diff --git a/docs/superpowers/plans/2026-07-28-redis-runtime-cache.md b/docs/superpowers/plans/2026-07-28-redis-runtime-cache.md new file mode 100644 index 0000000..ef4b894 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-redis-runtime-cache.md @@ -0,0 +1,64 @@ +# Redis Runtime And Semantic Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this +> plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Repository policy is +> human-only, so no step stages or commits changes. + +**Goal:** Replace the SDK-less Redis seam with an opt-in managed Lettuce runtime, a real Lua +executor, and a bounded semantic string-cache implementation. + +**Architecture:** A package-private runtime owns `RedisClient`, connection and synchronous binary +commands. The Lua executor uses the compiled catalog checksum and `EVALSHA`, falling back to `EVAL` +only for `NOSCRIPT`. A versioned binary envelope distinguishes positive, negative and incompatible +entries behind `CacheRegionPort`. + +**Tech Stack:** Java 21, Lettuce Core managed by Spring Boot 4 BOM, Spring Boot configuration +properties, JUnit 5, optional Docker-backed Redis qualification. + +--- + +### Task 1: Add the managed runtime and typed program execution + +**Files:** +- Modify: `src/adapter/outbound/cache-redis/build.gradle` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java` +- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java` +- Create: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java` + +- [x] Write failing tests for URI/timeout validation, lifecycle close, binary get/set/delete and + `EVALSHA -> NOSCRIPT -> EVAL`. +- [x] Confirm RED before adding the Lettuce production dependency. +- [x] Add `io.lettuce:lettuce-core` using the Boot BOM and update the affected dependency locks. +- [x] Implement a package-private runtime with finite command/shutdown timeouts, bounded reconnect + behavior, and no connection side effects while disabled or in external-client mode. +- [x] Verify focused tests GREEN. + +### Task 2: Implement the semantic cache region + +**Files:** +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java` +- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java` + +- [x] Write failing tests for hit, negative hit, miss, incompatible schema, positive/negative TTL, + invalidation and provider failure certainty. +- [x] Confirm RED. +- [x] Implement a bounded versioned binary envelope and HMAC-derived physical keys. Support UPSERT; + return `NOT_RECORDED_PROVIDER_POLICY` for opaque revision ordering the provider cannot prove. +- [x] Confirm GREEN and run the complete Redis leaf test suite. + +### Task 3: Qualify and document without false promotion + +**Files:** +- Modify: `src/adapter/outbound/cache-redis/README.md` +- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md` +- Modify: runtime configuration and env-key registry only for settings actually introduced. + +- [x] If a local Redis image is available, run an explicit real-service program/cache test; never + silently skip it. +- [x] Mark standalone runtime/cache as R1 unless real service, restart, ACL/TLS and fault evidence + required by the readiness card all pass. +- [x] Run the leaf check, dependency lock check, env-key gate and architecture gate. diff --git a/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md b/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md index abab727..7bec7e1 100644 --- a/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md +++ b/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md @@ -1,3 +1,7 @@ +> **SUPERSEDED — HISTORICAL PROVENANCE ONLY (2026-07-25):** The user-approved harness-free +> Mode B amendment supersedes this design. Retain the body as historical provenance; it is not +> executable instruction. + # Harness Policy Engine Refactoring Design - **Date:** 2026-07-20 diff --git a/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-harness-free-design.md b/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-harness-free-design.md new file mode 100644 index 0000000..54f38f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-harness-free-design.md @@ -0,0 +1,87 @@ +# Application Outbox Failure Reporting — Harness-Free Design + +## Context + +`application-core` currently carries Spring Boot and SLF4J only because +`PublishPendingOutboxEventsUseCase` renders relay failures itself. That reverses the diagnostic +dependency direction and also permits a duplicate WARN in `OutboxMessagePublishAdapter`. + +This change is harness-free: `src/config/architecture/modules.json`, Gradle, ArchUnit, and focused +module tests are the policy and evidence authorities. No `.harness` files or public paths change. + +## Boundary + +`application-core` owns a specific `OutboxRelayFailureReportPort` and an immutable +`OutboxRelayFailureReport`. The report is an allowlist containing only: + +- `OperationalError code` +- event, aggregate, and correlation identifiers +- event type, attempt count, optional next-attempt time +- the originating `RuntimeException` + +It never carries the payload, idempotency key, message template, severity, arbitrary fields, or the +whole `OutboxEvent`. Factories and record invariants admit only retryable +`OUTBOX_PUBLISH_FAILED` reports with a next-attempt time and terminal `OUTBOX_DEAD_LETTER` reports +without one. + +`adapter:outbound:messaging` owns `Slf4jOutboxRelayFailureReportAdapter`. It maps the typed report to +one canonical SLF4J 2 fluent ERROR with fixed key names and runbook links. Bootstrap only wires the +port. + +## Ordering and Failure Semantics + +The persisted FAILED or DEAD transition is authoritative: + +1. broker publication fails; +2. the application calculates the transition; +3. the store transition succeeds inside `TransactionPort`; +4. only then is the typed report emitted. + +A transition failure propagates and emits no report. A reporter `RuntimeException` is contained by +both the adapter and the use case, so it cannot change the relay outcome or prevent later events +from running. Successful publication and `markPublished` failures emit no failure report. + +There is no production no-op reporter. `MessagingConfig` always contributes exactly one reporter +bean, using the configured broker name or `disabled` when blank. `OutboxMessagePublishAdapter` +becomes mapping/send-only: runtime failures propagate, checked failures are wrapped with their +cause, and it emits no success or failure log. The general `OutboundMessagePublisher` retains its +existing fail-open dependency logging. + +## Structured ERROR Contract + +Every confirmed transition produces one ERROR with the common fields: + +`error.code`, `error.category`, `dependency_name`, `dependency_type=messaging`, `outcome`, +`event_id`, `event_type`, `aggregate_id`, `correlation_id`, `attempt_count`, and `runbook_link`. + +Retryable failures additionally carry `next_attempt_at`. Mappings are: + +| Code | Outcome | Runbook | +| --- | --- | --- | +| `OUTBOX_PUBLISH_FAILED` | `FAILED` | `runbook://outbox/publish-failed` | +| `OUTBOX_DEAD_LETTER` | `DEAD` | `runbook://outbox/dead-letter` | + +The originating exception is attached as the throwable. Payload, idempotency key, envelope data, +message templates derived from the exception, and arbitrary exception fields are forbidden. +The adapter's fail-open boundary also applies to invalid direct calls: `report(null)` must never +throw. The focused structured-adapter test pins this behavior. + +## Enforcement and Tests + +- Value tests enforce invariants and reflectively pin the exact record component allowlist. +- Relay tests pin transition-before-report ordering, no-report paths, exact cardinality, and + reporter containment. +- Messaging tests capture Logback events and pin level, fields, throwable, and unsafe-data absence. +- `verifyApplicationCoreDependencyPurity` rejects non-project production declarations and forbidden + Spring/logging/metrics groups on resolved application classpaths. +- `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK` bans SLF4J, JUL, Logback, Log4j, and Micrometer from + the exact `dev.caskeleton.application..` scope. Its dedicated violation fixture also resides + inside that scope, under `dev.caskeleton.application.architecture.violations`, proving the rule + is non-vacuous. +- `application-core` test dependencies are reduced to JUnit Jupiter and AssertJ; all other leaves + keep the shared Spring Boot test baseline. + +## Scope + +No public path, CI workflow, module-registry edge, payload shape, outbox persistence schema, or +general publisher logging behavior changes. Agents do not stage, commit, amend, or push. diff --git a/docs/superpowers/specs/2026-07-25-harness-free-mode-b-amendment.md b/docs/superpowers/specs/2026-07-25-harness-free-mode-b-amendment.md new file mode 100644 index 0000000..1990663 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-harness-free-mode-b-amendment.md @@ -0,0 +1,59 @@ +# Harness-Free Mode B Amendment + +- **Date:** 2026-07-25 +- **Status:** Approved scope amendment +- **Mode:** B — controlled reconstruction from repository evidence +- **Supersedes:** `2026-07-20-harness-policy-engine-design.md` and + `2026-07-20-harness-policy-engine.md` in full as executable guidance; both superseded documents + remain only as historical provenance + +## Decision + +The repository will recover Gradle configuration and Clean Architecture dependency enforcement +without reconstructing the absent development harness. A Gradle-owned JSON registry at +`src/config/architecture/modules.json` becomes the single source of truth for the current 19 leaf +modules, their repository-relative source paths, Gradle paths, and allowed production project +dependencies. + +Both `src/settings.gradle` and `verifyCleanArchitectureDependencies` consume that file. Settings +validation fails closed for malformed, empty, duplicate, unsafe, or missing module entries. The +dependency gate continues to require complete leaf coverage and reject unapproved production +project edges; production leaves may never depend on the `sample-portfolio` fixture consumer. + +## Evidence and provenance + +Registry entries are reconstructed from the checked-in Gradle topology and each leaf +`build.gradle`'s `api`, `implementation`, `compileOnly`, and `runtimeOnly` project dependencies. +Test-only and fixture-only configurations are not architecture production edges. This is Mode B +provenance: it restores the repository's observable build contract, not unavailable historical +artifacts. + +The pre-change RED command is: + +```bash +cd src +./gradlew help --console=plain +``` + +It fails because `src/settings.gradle` requires the absent +`.harness/project/modules.yaml`. + +## Explicit non-goals + +- No `.harness/` tree, task resolver, task packet, or policy-hash runtime. +- No `.agents/`, `.claude/`, `.codex/`, agent plugin, hook, renderer, or platform parity + reconstruction. +- No production Java or runtime behavior change. +- No byte-identical restoration claim. +- No claim that the earlier Harness Policy Engine plan or the broader refactor is complete. + +## Enforcement and workflow + +Gradle and CI gates replace harness runtime dependencies for module discovery and dependency +policy. Root and module guidance point to the Gradle-owned registry and retain the eight local +HARD-STOP meanings, architecture responsibilities, focused-test discipline, human-only git +policy, and LLM Wiki capture workflow. + +Acceptance requires successful Gradle `help`, `projects`, and +`verifyCleanArchitectureDependencies`, an independent deterministic 19-leaf registry check, +`git diff --check`, and a reviewed working-tree status. diff --git a/docs/superpowers/specs/2026-07-25-harness-free-quality-security-ci-design.md b/docs/superpowers/specs/2026-07-25-harness-free-quality-security-ci-design.md new file mode 100644 index 0000000..e6b9078 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-harness-free-quality-security-ci-design.md @@ -0,0 +1,97 @@ +# Harness-Free Quality and Security CI Design + +- **Date:** 2026-07-25 +- **Status:** Approved Mode B reconstruction +- **Scope:** Repository-internal quality, dependency-vulnerability, and link-check controls + +## Decision and provenance + +Mode B reconstructs observable CI contracts from the current Gradle build, active documentation, +and the incomplete `/home/donghyeon/dev/ca-tmpl` checkout. The candidate checkout is evidence, not +an authoritative or byte-identical restoration source. Its useful policy is adapted to the current +`main` branch and current tasks; stale `master`, feature-branch ownership, and absent workflow +claims are removed. + +`.github/workflows/` is the canonical workflow path. No `.gitea/workflows` shadow is created. The +origin is Gitea, but server-side Actions is externally disabled, so these files define repository +controls without claiming that remote jobs currently execute. + +Every external `uses:` reference is pinned to a verified 40-character commit SHA. Its immutable +release tag remains beside the SHA as an inline review label; moving major-version tags are not an +execution authority. + +## Scope boundary + +This slice owns: + +- pinned Java tool evidence and text/binary normalization; +- structured Trivy suppression governance and CODEOWNERS review surfaces; +- the quality-gate matrix and its drift verifier; +- quality, filesystem vulnerability, and documentation-link workflows; +- human-readable dependency severity, suppression, network, and forge-compatibility policy. + +The development harness remains excluded: no `.harness`, `.agents`, `.claude`, or `.codex` +runtime is reconstructed. Build/release supply-chain, tag release, image scanning, signing, +provenance, SBOM, retention, and Docker root-context work belongs to the later Phase A2 slice and +is not represented as a present workflow job. + +## Considered approaches + +1. Copy the candidate files unchanged. Rejected because they target `master`, refer to missing + supply-chain scripts/jobs, and describe obsolete branch ownership. +2. Reconstruct a minimal current control plane from repository evidence. Selected because every + gate can be checked against a present Gradle task, test, script, or workflow job. +3. Merge all checks into one workflow. Rejected because GitHub-only dependency APIs need forge + guards, scheduled vulnerability scans have different triggers, and link checks are path-scoped. + +## Components and gate flow + +`ci-quality-gates.yml` runs three required jobs: the aggregate Gradle quality suite, the sample-off +axis, and gate-matrix lint. Before Java setup or Gradle, the quality job requires +`docs/security/public-paths-snapshot.txt` to be committed and non-empty. The worktree now contains +the canonical baseline for `/api/healthcheck`; because agents do not stage or commit, a human must +track and commit it before CI's `git ls-files` precondition can pass. This prevents +`verifyPublicPathSnapshot` from creating a first-run baseline inside CI and passing without +comparison. + +`release-gate` uses `if: always()` and accepts only `success` from those three jobs; the advisory +quarantine job is deliberately outside its `needs`. + +The quality aggregate runs `check`, `verifyPublicPathSnapshot`, and `verifyDependencyLocks` +explicitly. `check` already pulls in Clean Architecture dependency enforcement, environment/readme +drift checks, Trivy-ignore governance, format/static analysis, normal tests, and quarantine sunset. + +`dependency-vulnerability.yml` keeps GitHub Dependency Graph operations behind +`github.server_url == 'https://github.com'`. Platform-neutral `trivy-fs` runs for PR, `main` push, +daily schedule, and manual dispatch. Trivy and jq install into `${RUNNER_TEMP}` and expose their +directories through `${GITHUB_PATH}`. Every Trivy scan names `.trivyignore.yaml`; High/Critical and +KEV matches block, while Medium/Low only report. The KEV gate first rejects blank metadata, +non-positive/non-integral or mismatched counts, empty arrays, invalid CVE identifiers, and duplicate +identifiers. It separately rejects malformed/empty Trivy JSON before extracting candidate IDs. +Dependency review reports through its check only and does not request permission to write a PR +summary comment. Vulnerability DB, tool release, malformed/empty KEV or Trivy data, and KEV feed +network failures remain blocking unless internal mirrors are configured. + +`link-check.yml` is path-scoped for PR and `main` push, and remains manually runnable. + +## Drift verification and failure behavior + +`.github/ci-gate-matrix.yml` lists only current mechanisms/jobs. The verifier resolves the +repository root from its own physical location, rejects incomplete/duplicate records, and checks +referenced Gradle custom tasks, plugins, contract-test files, workflow files, and job IDs. +Delegated-pending is supported only when a row is explicitly marked; no absent supply-chain job is +invented in this slice. + +The CI release fan-in fails for failed, cancelled, or unexpectedly skipped required jobs. Trivy's +KEV feed cross-check is fail-closed. GitHub-only jobs may skip by their explicit forge/event +conditions and are not dependencies of the quality release fan-in. + +## Verification + +Acceptance requires the prescribed RED for the absent `.trivyignore.yaml`, GREEN +`verifyTrivyignore`, proof that the snapshot precondition rejects missing, empty, or untracked +baselines, and a human-tracked canonical snapshot for CI. It also requires strict synthetic KEV +catalog negative/positive cases, shell syntax and matrix verification, workflow YAML/static checks, +evidence that `main` is the only active branch trigger, Trivy ignorefile coverage, exact release +fan-in, absence of harness/Gitea shadow workflows, `git diff --check`, and reviewed working-tree +status. Network Trivy scans are intentionally not run locally. diff --git a/docs/superpowers/specs/2026-07-25-module-gradle-hygiene-harness-free-design.md b/docs/superpowers/specs/2026-07-25-module-gradle-hygiene-harness-free-design.md new file mode 100644 index 0000000..70aa817 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-module-gradle-hygiene-harness-free-design.md @@ -0,0 +1,173 @@ +# Harness-Free Module and Gradle Hygiene Design + +- **Date:** 2026-07-25 +- **Status:** Approved +- **Mode:** B reconstruction without `.harness` +- **Scope:** all 19 Gradle leaves, dependency declarations, test baselines, Mongo scaffolding, + runtime-composition documentation, and dependency locks +- **Topology SSOT:** `src/config/architecture/modules.json` + +## 1. Context + +The 19-leaf project dependency graph obeys the registered allowed edges, and the three core +production source sets are free of Spring, persistence, transport, logging, and metrics imports. +The audit nevertheless found a wider declared graph than the source graph, Spring WebMVC test +libraries on pure-core test classpaths, Boot 3-era OpenAPI tooling on Spring Boot 4, example-domain +code in the production Mongo adapter, and direct MDC access in sample application services. + +This design follows the user-approved Mode B reconstruction. It does not recreate or depend on +`.harness`; settings and verification continue to consume the JSON registry. + +## 2. Goals + +1. Keep the exact 19 leaves and all allowed project edges in the JSON registry. +2. Remove only dependencies proven unnecessary by source/test inspection plus focused + compile/test verification. +3. Give `domain-core`, `application-core`, and `shared-contract` JUnit/AssertJ-only test + classpaths. +4. Keep Spring Boot 4.0.0 and replace `springdoc-openapi` 2.x with the Boot 4-compatible 3.0.0 + line. +5. Remove unused direct Jackson 2 declarations from GraphQL and WebSocket. +6. Require the Spring configuration processor exactly in leaves whose main source declares + `@ConfigurationProperties`. +7. Remove adapter-local `Example*` business concepts from `persistence-mongo`; retain only + opt-in Mongo infrastructure and typed enablement settings. +8. Replace sample application-layer MDC reads with an application-owned correlation-context port + implemented by the inbound web adapter. +9. Remove tracked jqwik runtime state and ignore future `.jqwik-database` files. +10. Describe the default bootstrap as the default runtime composition, not as wiring every + optional leaf. +11. Regenerate only affected strict dependency locks and finish with the full release gates. + +## 3. Non-goals + +- No endpoint, persistence schema, public response, outbox transition, or sample-domain behavior + change. +- No version catalog, convention-plugin, `buildSrc`, module rename, or registry schema expansion. +- No automatic addition of GraphQL, gRPC, WebSocket, Mongo, file server, or object storage to the + default `app-bootstrap` runtime. +- No stage, commit, amend, or push. + +## 4. Approved dependency decisions + +An allowed registry edge is permission, not an obligation to declare it. + +| Leaf | Remove after focused proof | Preserve | +| --- | --- | --- | +| `application-core` | unused `domain-core` edge | `shared-contract` | +| `inbound:web` | unused `domain-core` edge | application/shared and transport dependencies | +| `inbound:graphql` | application/domain edges, direct Jackson 2, unused processor | shared and GraphQL/web test transport | +| `inbound:grpc` | application/domain edges, unused annotations/direct protobuf declarations | shared, netty, services, configuration processor | +| `inbound:websocket` | application/shared edges, direct Jackson 2 | domain, WebSocket, configuration processor | +| `outbound:support` | domain/application/shared edges | autoconfigure and SLF4J API | +| `outbound:cache-redis` | domain/application, unused Groovy/Spock | shared/support | +| `outbound:httpclient` | domain/application | shared/support, actual Groovy/Spock tests | +| `outbound:identifier` | domain, `uuid-creator` | application, actual Groovy/Spock tests | +| `outbound:messaging` | domain, unused Groovy/Spock | application/shared/support/SLF4J | +| `outbound:notification` | domain, unused Groovy/Spock | application/shared/support/web/SLF4J | +| `outbound:persistence-jpa` | domain; explicit Flyway core only if focused compile proves the starter sufficient | application/shared/JPA/PostgreSQL | +| `outbound:persistence-mongo` | application/shared, `Example*`, example Testcontainers tests | Mongo opt-in infrastructure/settings | +| `outbound:fileserver` | broad Boot starter | application/shared, autoconfigure, SLF4J | +| `outbound:objectstorage` | broad Boot starter | application/shared/AWS, autoconfigure, SLF4J, vendor IT | + +Production composition-root dependencies remain even when bootstrap source does not statically +import their types: their purpose is runtime assembly. Duplicate test declarations may be removed +only when the focused test classpath continues to compile and execute. + +## 5. Pure-core test and verification policy + +`domain-core`, `application-core`, and `shared-contract` receive only JUnit Jupiter, AssertJ, and +the JUnit launcher from the root convention. All other leaves keep the existing Spring test +baseline in this change; family-wide convention plugins are out of scope. + +The existing application dependency-purity gate remains. A new registry-driven configuration +processor parity gate applies this Boolean invariant to every leaf and is wired into `check`: +main source contains one or more exact `@ConfigurationProperties(` occurrences if and only if the +leaf `build.gradle` contains exactly one Spring configuration-processor declaration. It must ignore +`@ConfigurationPropertiesScan`; the number of settings classes is not compared with the number of +processor declarations. + +## 6. Spring Boot 4 compatibility + +The web adapter changes +`org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6` to `3.0.0`, the first stable +springdoc line released for Spring Boot 4.0.0. The existing sample tests that boot a real server +and call `/v3/api-docs` are the behavior gate. Snapshot changes are accepted only if they are a +deterministic library-version result and retain the public API contract. + +Springdoc 3 otherwise widens `ApiError.details` from the committed `type: object` to an +unconstrained OAS 3.1 schema. A web-adapter-owned `OpenApiCustomizer` must restore the object schema +in the final generated document. Both real-server test applications import that production +configuration. `shared-contract` remains free of Swagger annotations and dependencies. + +GraphQL and WebSocket remove direct `com.fasterxml.jackson` declarations because neither source +set imports them and Spring Boot 4 owns its JSON stack through the relevant starters. +The web adapter retains the `JsonNullable` value type, but its `0.2.6` artifact also declares +Jackson 2 transitively while this repository supplies explicit Jackson 3 serializers. Before and +after dependency insight plus focused present/null/undefined serialization tests determine whether +that transitive edge can be excluded. Exclusion is applied only if those tests and the real-server +OpenAPI tests pass; springdoc/Swagger's independently required JSON graph is not removed by +assumption. + +## 7. Mongo production boundary + +Delete the adapter-local `ExampleRecord`, document, mapper, repository, repository adapter, and +their tests. `MongoPersistenceConfig` remains conditional on +`ca-skeleton.persistence-mongo.enabled=true` and explicitly imports the Mongo client/data +auto-configurations without owning a fake business repository. + +The starter also registers Mongo auto-configuration directly through Boot metadata, independently +of `MongoPersistenceConfig`. A module-level `AutoConfigurationImportFilter`, registered through +Boot 4's `META-INF/spring.factories` discovery path, must exclude the Boot 4 sync/reactive client, +data, repository, health, and metrics Mongo auto-configurations while the enable property is absent +or false. It must allow them unchanged when the property is true; consumers must not need to set +`spring.autoconfigure.exclude`. + +Replacement tests must prove: + +- an actual `@EnableAutoConfiguration` context in default/false mode creates no Mongo + infrastructure; +- properties bind the enable flag; +- enabled mode can create the infrastructure with a supplied mock `MongoClient`, without a real + network connection; +- production source contains no `Example*` type. + +The Testcontainers dependencies leave this module when the example repository IT is removed. + +## 8. Correlation context boundary + +`application-core` owns a framework-free `CorrelationIdPort` whose read result is optional. +`adapter:inbound:web` implements it from the sanitized request MDC correlation key. +`CreateWorkLogUseCase` and `PosterEventPublisher` depend only on the port and preserve the current +fallback to the generated event id when no correlation id exists. + +Tests first pin present/blank/absent behavior and prove the sample application packages no longer +import SLF4J/MDC. Diagnostic storage remains an adapter concern. + +## 9. Runtime composition and generated state + +`app-bootstrap` keeps its current default runtime modules. Its build description and README must +state that optional leaves require an explicit registry and composition-root dependency change. +Optional adapters remain independently buildable and testable. + +The tracked four-byte `src/sample-portfolio/.jqwik-database` is generated runtime state. Delete it +and add `.jqwik-database` to `src/.gitignore`; retain jqwik itself because property tests use it. + +## 10. Verification + +Run focused compile/tests before and after each dependency group. Regenerate locks only through +each affected leaf's `:leaf-path:resolveAndLockAll --write-locks` task, then run: + +```bash +cd src +./gradlew check --console=plain +./gradlew test --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew verifyApplicationCoreDependencyPurity --console=plain +./gradlew verifyConfigurationPropertiesProcessor --console=plain +./gradlew verifyDependencyLocks --console=plain +./gradlew verifyPublicPathSnapshot verifyEnvKeys --console=plain +``` + +Completion requires fresh review, `git diff --check`, and an LLM Wiki branch note or an explicit +capture blocker for the mandated exact vault path. diff --git a/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md b/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md new file mode 100644 index 0000000..a31405d --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md @@ -0,0 +1,2467 @@ +# Fileserver Production Capability Deep Design + +- 작성일: 2026-07-26 +- 상태: 상세 설계 완료, Phase 0–1 및 Phase 2 일부 local R1 구현, R2 이상 미구현 +- 독립 아키텍처 재리뷰: blocker/high 0건 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-fileserver` +- 구현 추적: 이 문서의 목표 전체가 아니라 framework-free port, local staged CSV, single-node + operation journal/recovery까지만 적용되었다. +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) + +## 0. 구현 상태 + +2026-07-28 기준 구현된 범위: + +- `application-core`의 `FilePublicationPort`, typed schema/cell/row, producer/sink, opaque receipt; +- 행 단위 UTF-8 CSV encoding과 row/byte/cell 제한; +- spreadsheet formula 완화 정책과 SHA-256/count 계산; +- private staging, exclusive create, file/directory force, no-replace hard-link publish; +- 실패 시 staging 정리와 `PUBLISH_INDETERMINATE` 오류 분류; +- opt-in typed settings와 local R1 bean composition; +- canonical request fingerprint와 private `WRITING`/`SEALED`/`PUBLISHED` operation journal; +- forced temp record + atomic replace와 single-node terminal receipt restoration; +- sealed staging/final의 size·SHA-256 검증 후 producer 재실행 없는 local reconciliation. +- operation-scoped JVM/OS file lock과 hard-link-only publication protocol; +- overwrite-capable legacy port의 별도 opt-in/root 및 canonical overlap 차단; +- 안전한 commit primitive가 없을 때 copy-to-final로 downgrade하지 않는 fail-closed 동작. + +아직 구현되지 않은 범위: + +- Phase 2의 cross-node fencing, reference/private-manifest index, exhaustive crash/symlink-race + qualification; +- 운영 cleanup/quota/retention과 effective capability probe인 Phase 3; +- SFTP provider인 Phase 4; +- NFS/HA/bootstrap evidence인 Phase 5; +- optional delete/read/scan operation인 Phase 6. + +따라서 현재 journal은 single-node local recovery seam이며 Fileserver R2 완료 증거가 아니다. +기존 `FileExportPort`도 호환성을 위해 +남아 있으며, 전체 행 materialization과 absolute path receipt를 사용하는 legacy 경로다. + +## 1. 설계 판정 + +현재 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의 로컬 +CSV 예제다. + +```text +List> + -> 전체 StringBuilder + -> 전체 String + -> 전체 byte[] + -> final path 직접 truncate/write + -> absolute server path 반환 +``` + +목표는 범용 파일시스템 CRUD API가 아니다. 목표는 다음 capability다. + +> 계층형 경로, 외부 파일명, drop-zone, rename 또는 ready-marker 완료 계약이 필요한 생성 +> 파일을 local/mounted filesystem 또는 SFTP에 안전하게 publish하고, 그 결과를 opaque +> reference로 추적·검증·복구하는 기능 + +선택한 핵심 구조는 다음과 같다. + +1. Application은 목적지를 path/host가 아닌 `FileDestinationId`로 선택한다. +2. 안정적인 `FilePublishOperationId`가 retry와 reconciliation의 기준이다. +3. 행 데이터는 동기식 row-producer callback으로 한 번만 흘려보낸다. +4. Adapter가 staging, encoding, checksum, close, publish, abort를 모두 소유한다. +5. Provider는 설정값이 아니라 실제 probe 결과인 effective capability를 보고한다. +6. 요구한 atomicity/durability보다 provider 보장이 약하면 자동 downgrade하지 않는다. +7. publish 응답 유실은 실패가 아니라 `INDETERMINATE`로 모델링하고 먼저 reconcile한다. +8. 기본 파일명 정책은 immutable/versioned이며 unconditional overwrite와 append는 금지한다. +9. File Server와 business database 사이의 원자적 commit은 주장하지 않는다. +10. 사용하지 않는 provider는 connection, scheduler, scan, directory 생성 같은 side effect를 + 일으키지 않는다. + +## 2. 기존 통합 설계에서 다룬 범위와 이번 심화 범위 + +상위 설계서는 Fileserver에 대해 다음 운영 기준만 정의했다. + +- streaming writer; +- temporary file; +- restrictive permissions; +- fsync와 atomic rename; +- symlink defense; +- quota와 retention; +- CSV formula defense; +- opaque receipt; +- NFS/SFTP semantics를 provider capability로 표시. + +이 기준은 방향은 맞지만 다음 구현 결정이 없었다. + +- streaming port의 정확한 signature와 lifecycle; +- producer exception과 storage exception의 분리; +- operation ID와 request-intent/operation fingerprint; +- publish state machine과 unknown outcome; +- create-only, replace, versioned naming의 정확한 보장; +- atomic visibility와 crash durability의 분리; +- local, NFS, SFTP의 effective capability matrix; +- target-side staging과 cross-filesystem 처리; +- mount 누락 시 local fallback 방지; +- SFTP extension negotiation과 reconnect 후 reconciliation; +- manifest/marker commit protocol; +- multi-node cleanup claim; +- quota reservation의 한계; +- CSV dialect, typed cell, null, control character, formula 정책; +- error taxonomy, health, metric, test 및 CI task; +- 기존 API에서 새 API로의 migration. + +이번 문서는 위 항목을 구현 계획을 작성할 수 있는 수준까지 확정한다. + +## 3. 현재 코드의 증거 기반 진단 + +| 영역 | 현재 구현 | 운영상 의미 | +| --- | --- | --- | +| Port | `exportCsv(String, List, List>)` | 호출자가 전체 행을 먼저 메모리에 적재해야 한다. | +| Encoding | `StringBuilder -> String -> byte[]` | 파일 크기에 비례한 heap 복제가 추가된다. | +| Write | `Files.write(finalTarget, bytes)` | 기존 파일을 truncate하고 partial final을 노출할 수 있다. | +| Naming | caller-controlled `fileName` | namespace, operation ID, version, precondition이 없다. | +| Receipt | `ExportedFile.path` absolute path | SFTP와 cluster에서 의미가 없고 서버 topology가 유출된다. | +| Path safety | `normalize().startsWith(baseDir)` | lexical traversal만 막고 symlink/TOCTOU를 막지 못한다. | +| CSV | UTF-8, LF, 최소 quoting | RFC 4180 CRLF, row width, formula, control/size 정책이 없다. | +| Collision | unconditional overwrite | concurrent writer의 결과가 정의되지 않는다. | +| Durability | 없음 | file force, directory sync, remote fsync를 구분하지 않는다. | +| Error | 모든 `IOException`을 `INTERNAL_ERROR` | retry, conflict, capacity, unknown outcome을 구분할 수 없다. | +| Settings | `enabled`, 상대 `baseDirectory` | provider, guarantee, limits, timeout, mount identity가 없다. | +| Startup | enabled이면 `createDirectories` | NFS mount 누락을 로컬 디렉터리로 오인할 수 있다. | +| Composition | leaf는 registry에만 등록 | 기본 `app-bootstrap` artifact에 fileserver가 없다. | +| Consumer | 없음 | sample을 포함해 실제 use case가 port를 호출하지 않는다. | +| Tests | local unit 7개 | concurrency, fault, symlink, memory, NFS, SFTP 증거가 없다. | + +주요 근거 파일: + +- `src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java` +- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java` +- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java` +- `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java` +- `src/config/architecture/modules.json` +- `src/app-bootstrap/build.gradle` + +현재 7개 fileserver unit test와 leaf `check`는 성공한다. 이는 현재 문서화된 로컬 happy-path +계약이 동작한다는 증거일 뿐 production readiness 증거는 아니다. + +## 4. 범위와 명시적 비범위 + +### 4.1 이번 R2 baseline에 포함 + +- tabular data의 streaming CSV publication; +- local persistent filesystem provider; +- mounted/shared filesystem provider profile; +- SFTP outbound publication provider; +- staging, sealing, publish, abort; +- SHA-256, byte/row count, versioned manifest; +- create-unique 및 conditional replace; +- operation-id 기반 idempotency와 reconciliation; +- opaque inspection/reference; +- managed namespace의 staging cleanup; +- opt-in managed retention/delete; +- typed settings, exact provider binding, startup safety; +- health, metrics, traces, structured error; +- real local/OpenSSH/NFS integration and failure tests. + +### 4.2 안전하게 열어둘 optional operation + +- opaque reference 기반 metadata inspection; +- opaque reference 기반 content transfer; +- expected-version 기반 managed delete; +- operation ID 기반 publish resolution; +- marker/manifest verification. + +이 operation은 구현할 수 있게 계약을 분리하지만 하나의 범용 `FileServerPort` CRUD로 합치지 +않는다. + +### 4.3 이번 범위에서 제외 + +- HTTP download endpoint와 authorization: `adapter:inbound:web` 책임; +- 사용자 multipart upload: inbound transport와 object-storage/quarantine workflow 책임; +- presigned URL, multipart object upload, ETag/bucket lifecycle: object storage 책임; +- domain별 export job entity, snapshot query, pagination: application/sample 책임; +- DB write와 file publish의 distributed transaction; +- inbound SFTP/NFS pickup으로 use case를 구동하는 기능; +- directory watch/poll을 business event source로 사용하는 기능; +- FTP/FTPS/SMB native client provider; +- 제품별 성능 수치와 partner별 filename/dialect. + +외부 파일이 들어와 use case를 구동하는 inbound pickup은 driving adapter다. 필요해지면 +`adapter:inbound:fileserver` leaf를 registry migration으로 추가한다. 현재 outbound leaf에 +listener/poller와 business handler를 넣지 않는다. + +## 5. HARD invariants + +다음 조건은 구현 편의를 위해 낮출 수 없다. + +1. `application-core`에 Spring, `Path`, `File`, `InputStream`, SFTP SDK, Micrometer 타입을 + 노출하지 않는다. +2. Application request에는 host, port, credential, base directory, remote path가 없다. +3. Physical final path는 adapter의 target binding에서만 계산한다. +4. Caller 문자열을 `Path.resolve`의 자유 경로로 사용하지 않는다. +5. Final name을 가진 파일에 직접 streaming write하지 않는다. +6. Temp/staging은 publish target과 동일 filesystem/remote namespace에 존재한다. +7. `ATOMIC_MOVE` 실패 시 non-atomic move/copy로 조용히 fallback하지 않는다. +8. Atomic visibility와 crash durability를 같은 guarantee로 표현하지 않는다. +9. 하나의 accepted publish attempt 안에서 producer는 최대 한 번만 실행한다. Process 또는 + node 장애를 넘는 global at-most-once는 durable ownership evidence 없이는 주장하지 않는다. +10. 같은 accepted attempt의 provider retry를 위해 application row producer를 다시 호출하지 + 않는다. +11. `INDETERMINATE` 결과를 blind retry하지 않는다. +12. `APPEND`는 R2 publication mode가 아니다. +13. Default collision policy는 unconditional replace가 아니다. +14. File name/path/content/checksum/user/tenant를 metric tag로 사용하지 않는다. +15. Unknown file과 유효하지 않은 manifest를 reaper가 자동 삭제하지 않는다. +16. Fileserver가 persistence, Redis lock, objectstorage adapter에 의존하지 않는다. +17. `local-dev` provider는 prod profile에서 시작하지 않는다. +18. SFTP unknown host key 허용, password literal, infinite timeout/pool은 금지한다. +19. 현재 mount가 기대한 mount인지 증명하지 못하면 mounted provider는 ready가 아니다. +20. Provider capability가 요구 guarantee를 만족하지 못하면 startup 또는 required readiness를 + 실패한다. + +## 6. 대안 검토 + +### A. 현재 port를 유지하고 `BufferedWriter`만 추가 + +Adapter의 추가 `StringBuilder`는 제거할 수 있지만 caller가 이미 `List>` 전체를 +materialize한다. Absolute path, overwrite, provider, unknown outcome 문제도 남는다. 선택하지 +않는다. + +### B. 범용 `FileServerPort`에 open/read/write/list/move/delete를 모두 제공 + +Application이 path, directory, wildcard, file handle에 결합되고 raw filesystem facade가 된다. +Redis의 raw command API를 core에 노출하지 않는 것과 같은 이유로 선택하지 않는다. + +### C. Mutable `begin -> append -> commit/abort` session을 application에 노출 + +페이지 단위 쓰기는 쉽지만 caller가 commit/abort/close를 누락할 수 있고 adapter resource +lifecycle이 application으로 새어 나온다. SDK-like session이 되므로 기본 계약으로 선택하지 +않는다. + +### D. 단일 publish 호출과 synchronous row-producer callback + +선택한 방식이다. + +- Adapter가 staging부터 abort까지 전 lifecycle을 소유한다. +- Sink의 동기 호출 자체가 backpressure boundary가 된다. +- Producer는 페이지 조회를 선택할 수 있어 전체 materialization이 필요 없다. +- Adapter가 producer를 한 번만 실행하는 것을 통제할 수 있다. +- Checked IO와 provider 타입이 application으로 새지 않는다. + +### E. Provider별 Gradle leaf를 즉시 분리 + +`fileserver-local`, `fileserver-mounted`, `fileserver-sftp`는 dependency isolation이 가장 +강하지만 지금은 exact-19 registry 변경과 설정/계약 중복 비용이 크다. 우선 기존 leaf 내부 +provider package로 구현한다. SFTP SDK 보안/릴리스 lifecycle이 독립 배포를 요구할 때 ADR과 +registry migration으로 분리한다. + +## 7. 목표 아키텍처 + +```mermaid +flowchart LR + USECASE[Application export use case] --> PORT[FilePublicationPort] + PORT --> COORD[Publication coordinator] + COORD --> VALIDATE[Schema / limits / formula policy] + VALIDATE --> CSV[Streaming CSV encoder] + CSV --> PIPE[Count + digest + optional transform] + PIPE --> PROVIDER{Target provider} + PROVIDER --> LOCAL[Local persistent filesystem] + PROVIDER --> MOUNT[Mounted/shared filesystem] + PROVIDER --> SFTP[SFTP remote endpoint] + COORD --> MANIFEST[Manifest / marker / reconcile] + BOOT[app-bootstrap] -. binds destination and verifies guarantees .-> COORD + OBS[Health / metrics / traces] -. observes .-> COORD +``` + +Target package shape: + +```text +adapter:outbound:fileserver + config/ + core/ + FilePublicationCoordinator + FileProvider + FileProviderDescriptor + PublicationPlan + format/csv/ + CsvEncoder + CsvDialect + FormulaPolicy + publication/ + StagingArtifact + ManifestCodec + PublishReconciler + provider/local/ + provider/mounted/ + provider/sftp/ + maintenance/ + StagingReaper + RetentionExecutor + observability/ +``` + +## 8. 모듈과 계층 소유권 + +| 책임 | 소유 모듈 | +| --- | --- | +| Export use case, destination intent, schema, row production | `application-core` 또는 feature application | +| Framework-free publication/read/delete ports와 값 타입 | `application-core` | +| Skeleton-wide file operational error와 provider descriptor 공통 값 | `shared-contract` | +| CSV encoding, staging, manifest, local/mounted/SFTP provider | `adapter:outbound:fileserver` | +| Provider selection, exact binding, prod safety, readiness composition | `app-bootstrap` | +| Export job persistence, source snapshot, keyset query | application + persistence adapter | +| HTTP response streaming | `adapter:inbound:web` | +| Incoming file pickup/listener | future inbound fileserver leaf | +| Mount, quota, backup, NFS export, remote account | deployment/IaC/operations | + +Fileserver leaf의 허용 edge는 현재처럼 `application-core`, `shared-contract`만 유지한다. +Sibling adapter edge를 추가하지 않는다. + +## 9. Application 계약 + +### 9.1 Port 분리 + +```java +public interface FilePublicationPort { + FilePublishReceipt publish( + FilePublishRequest request, + TabularRowProducer producer); +} + +public interface FilePublishResolutionPort { + FilePublishResolution resolve(FilePublishOperationId operationId); +} + +public interface PublishedFileInspectionPort { + PublishedFileMetadata inspect(PublishedFileReference reference); +} + +public interface PublishedFileTransferPort { + void transfer(PublishedFileReference reference, FileChunkSink sink); +} + +public interface ManagedFileDeletionPort { + FileDeletionReceipt delete( + PublishedFileReference reference, + FileVersion expectedVersion); +} + +public interface FileRegenerationPort { + FilePublishReceipt regenerate( + FileRegenerationRequest request, + TabularRowProducer producer); +} +``` + +R2 구현 순서는 publication과 resolution이 먼저다. Inspection, transfer, delete는 필요 없는 +애플리케이션에 bean과 runtime behavior를 만들지 않는다. Regeneration도 durable spool을 +복구할 수 없는 topology에서만 opt-in한다. + +금지되는 port: + +```text +open(Path) +list(String glob) +move(String from, String to) +delete(String rawPath) +getSftpSession() +``` + +### 9.2 Request + +`FilePublishRequest`는 다음 값만 가진다. + +| 필드 | 의미 | +| --- | --- | +| `operationId` | retry 전반에서 유지되는 안정적인 ID | +| `destinationId` | 설정에 등록된 logical destination | +| `logicalFileName` | display/naming input이며 path가 아님 | +| `sourceRevision` | snapshot 또는 source fingerprint | +| `schema` | column 이름, type, null/formula 정책 | +| `formatProfileId` | 등록된 CSV profile | +| `publishCondition` | create unique 또는 expected-version replace | +| `retentionClassId` | 등록된 관리 정책 | +| `protectionProfileId` | 등록된 민감도/transform 정책 | + +Request에 다음 값은 없다. + +- absolute/relative directory; +- provider ID; +- local/SFTP path; +- delimiter나 charset 임의 값; +- username, host, credential; +- POSIX mode; +- timeout과 pool size. + +Provider와 세부 정책은 `destinationId`의 bootstrap binding이 결정한다. + +### 9.3 Operation ID와 fingerprint + +`FilePublishOperationId`는 필수다. Caller intent와 runtime policy를 분리해 canonical하게 +계산한다. + +```text +requestIntentDigest = SHA-256( + destinationId + + logicalFileName normalized form + + sourceRevision + + schema version/digest + + formatProfileId + + publish condition + + protection profile ID + + retention class ID +) + +operationFingerprint = SHA-256( + requestIntentDigest + + effectivePolicyDigest +) +``` + +규칙: + +- 동일 ID + 동일 intent/frozen policy + PUBLISHED: producer를 실행하지 않고 기존 receipt 반환; +- 동일 ID + 다른 request intent: `FILESERVER_OPERATION_MISMATCH`; +- 동일 ID + active STAGING: in-progress 또는 bounded wait; +- 동일 ID + INDETERMINATE: reconcile만 수행; +- 새 ID: 새 publication. + +`effectivePolicyDigest`는 ID 문자열이 아니라 adapter가 first reservation 때 freeze한 canonical +policy snapshot의 digest다. + +```text +destination binding revision +naming policy revision +format profile revision + canonical options +publication/guarantee requirements +protection transform revision +payload encryption/signing key version (사용 시) +retention policy revision +ownership/control-plane mode +``` + +SFTP login private-key version처럼 payload 의미를 바꾸지 않는 transport credential은 operation +fingerprint에 넣지 않되 audit/session lifecycle에는 기록한다. 반대로 payload encryption key +version은 최종 bytes와 복구에 영향을 주므로 포함한다. Raw key material은 snapshot에 없다. + +기존 operation journal이 있으면 incoming `requestIntentDigest`를 먼저 비교한 뒤 현재 배포의 +same-name profile을 다시 해석하지 않고 journal에 freeze된 policy snapshot으로 +`operationFingerprint`를 복원한다. 해당 revision이나 key version을 더 이상 사용할 수 없으면 +현재 정책으로 조용히 바꾸지 않고 `FILESERVER_POLICY_REVISION_UNAVAILABLE`로 중단한다. 새 +operation만 현재 effective policy를 freeze한다. + +`accepted attempt`는 request/fingerprint 검증, capacity 획득, ownership 판정까지 성공해 새 +staging generation이 할당된 한 번의 실행이다. Pre-commit terminal failure 뒤 source +regeneration은 기존 operation을 다시 여는 방식이 아니라 별도 regeneration 계약으로 새 +operation ID를 만든다. + +Fileserver의 operation protocol은 business side effect exactly-once를 의미하지 않는다. + +### 9.4 Streaming row producer + +```java +@FunctionalInterface +public interface TabularRowProducer { + void produce(TabularRowSink sink); +} + +public interface TabularRowSink { + void write(TabularRow row); + void checkpoint(); +} +``` + +계약: + +- 새 payload 생성이 필요한 하나의 accepted attempt에서 `produce`는 caller thread에서 + 동기적으로 최대 한 번 호출된다. 이미 완료된 operation은 0회다. +- Sink는 thread-safe API가 아니며 producer가 다른 thread로 넘기거나 보관할 수 없다. +- 각 `write`가 실제 bounded encoding/write를 진행하므로 자연스러운 backpressure가 생긴다. +- Deadline, cancellation, row/byte limit은 sink가 매 호출 전후에 검사한다. Producer는 page + 조회 전후처럼 row를 쓰지 않는 구간에도 `checkpoint()`를 호출한다. +- Sink failure는 runtime application exception으로 producer stack을 중단시킨다. +- Producer가 던진 domain/application exception은 fileserver dependency error로 바꾸지 않는다. +- Adapter는 producer failure 시 staging만 abort하고 원래 exception을 다시 던진다. +- Cleanup failure는 원래 exception의 suppressed cause와 metric으로 남긴다. +- Adapter 내부 retry 때문에 producer를 다시 실행하지 않는다. + +대용량 export use case는 producer 내부에서 stable cursor/keyset으로 source를 page 단위 조회한다. +File IO 전체를 감싸는 장시간 DB transaction은 허용하지 않는다. + +이 callback은 arbitrary blocking code를 강제로 중단할 수 있는 hard-timeout 경계가 아니다. +`checkpoint()`와 thread interruption은 cooperative cancellation이며, JDBC/HTTP page source에는 +각 source port의 statement/request timeout도 별도로 설정한다. Producer가 interruption을 +무시하거나 외부 호출에서 영구 block될 수 있는 topology는 process/job 격리를 사용한다. + +### 9.5 Typed cell과 schema + +이미 문자열화된 임의 값만 받으면 locale, timezone, null 의미가 caller마다 달라진다. 따라서 +다음 작은 framework-free cell set을 제공한다. + +```text +TextCell +IntegerCell +DecimalCell +BooleanCell +DateCell +InstantCell +NullCell +``` + +임의 객체와 reflection serialization은 없다. + +`ExportSchema`는 다음을 고정한다. + +- schema ID와 version; +- ordered columns; +- column name; +- expected cell type; +- nullable; +- formula policy; +- max cell bytes override; +- canonical formatter 또는 caller-supplied text 허용 여부. + +`InstantCell` 기본 표현은 UTC ISO-8601, number는 locale-independent canonical form이다. Partner +format이 다르면 named format profile을 등록한다. + +### 9.6 Receipt + +```text +operationId +fileReference +destinationId +publishedFileName +fileVersion +formatProfileId +mediaType +charset +byteSize +dataRowCount +columnCount +sha256 +publishedAt +publicationGuaranteeAchieved +durabilityGuaranteeAchieved +formulaMitigatedCount +manifestSchemaVersion +effectivePolicyRevision +``` + +`PublishedFileReference`와 `FileVersion`은 opaque value다. Receipt에는 absolute path, SFTP URI, +host, username, credential, mount path가 없다. + +### 9.7 Safe operation catalog + +개발자에게 기본 제공하는 operation은 capability-oriented하다. + +| Operation | 제공 목적 | 금지되는 raw 대체 | +| --- | --- | --- | +| `publishTabular` | staged CSV publish | final path direct write | +| `resolvePublish` | unknown/in-progress reconciliation | blind retry | +| `inspectPublished` | opaque reference metadata | `stat(rawPath)` | +| `verifyPublished` | size/digest/marker 검사 | caller checksum 구현 | +| `transferPublished` | bounded chunk transfer | raw `InputStream` 반환 | +| `regenerateExplicit` | prior revision을 고정한 새 operation 생성 | stale retry의 producer 재실행 | +| `deleteManaged` | version/ownership 조건부 삭제 | `Files.delete(path)` | +| `reapStaging` | 알려진 staging만 cleanup | recursive base delete | + +각 operation은 atomicity scope, retry safety, state growth, provider requirement, failure result를 +capability card에 기록한다. + +### 9.8 Explicit regeneration + +일반 `publish` retry는 `REGENERATION_REQUIRED` operation의 producer를 다시 실행하지 않는다. +Application이 데이터 재조회와 재생성을 승인할 때만 별도 계약을 사용한다. + +```text +FileRegenerationRequest + priorOperationId + expectedPriorStateRevision + expectedPriorAttemptId + newRequest (full FilePublishRequest with a new operationId) + regenerationDecisionId + boundedReasonCode + policyMode = REUSE_FROZEN_POLICY +``` + +규칙: + +1. Authorized application use case가 principal/tenant/business 승인을 먼저 수행한다. + `regenerationDecisionId`는 그 결정을 audit에 연결하는 opaque ID이지 bearer credential이 + 아니다. +2. Adapter는 prior operation을 먼저 reconcile한다. Final commit 가능성이 남은 + `INDETERMINATE`에서는 regeneration을 거부한다. +3. Prior state가 정확히 `REGENERATION_REQUIRED`이고 expected revision/attempt가 일치해야 한다. +4. `newRequest.operationId`는 새 ID여야 하며 old journal을 다시 열지 않는다. + 나머지 request intent와 schema를 canonicalize해 prior `requestIntentDigest`와 비교한다. +5. Provider control plane의 고정 key `supersessions/{priorOperationId}.json`을 + exclusive-create하고 record 안에 `newRequest.operationId`, expected revision/attempt, + decision ID를 저장한다. +6. 같은 prior operation에 이미 다른 supersession이 있으면 + `FILESERVER_REGENERATION_CONFLICT`다. +7. 새 operation은 원래 freeze된 policy/source revision/request intent를 재사용한다. 현재 + 정책으로 바꾸고 싶으면 regeneration이 아니라 별도 신규 publication이다. +8. Prior sealed digest가 남아 있으면 regenerated payload digest가 정확히 일치해야 한다. + Digest 전에 실패한 attempt는 application이 repeatable source revision을 증명할 때만 + regeneration한다. +9. Provider가 exclusive supersession record를 보장하지 못하면 external job coordination이 + 필수며, required topology는 evidence 없이는 시작하지 않는다. + +상태는 `REGENERATION_REQUIRED -> SUPERSEDED(newOperationId)`로 닫고 새 operation이 +`RESERVING`에서 시작한다. 새 attempt가 실패해도 또 다른 regeneration을 만들지 않고 이미 +연결된 `newOperationId`를 resolve/retry한다. Supersession record가 journal state update보다 +우선하며, journal update 중 crash해도 fixed-key record에서 `SUPERSEDED`를 복원한다. + +## 10. Error와 outcome 모델 + +현재 모든 IO failure를 `INTERNAL_ERROR`로 묶는 방식을 제거한다. + +권장 skeleton-wide code: + +| Error | 의미 | 기본 retry | +| --- | --- | --- | +| `FILESERVER_DISABLED` | binding/provider 비활성 | false | +| `FILESERVER_INVALID_REQUEST` | schema/name/profile 위반 | false | +| `FILESERVER_OPERATION_MISMATCH` | operation ID fingerprint 충돌 | false | +| `FILESERVER_CONFLICT` | create/expected-version 충돌 | false | +| `FILESERVER_CAPACITY_EXCEEDED` | byte/row/disk/quota 제한 | 조건부 | +| `FILESERVER_PERMISSION_DENIED` | local/remote permission | false | +| `FILESERVER_UNAVAILABLE` | mount/session/provider unavailable | true | +| `FILESERVER_TIMEOUT` | acquire/connect/idle/overall timeout | true | +| `FILESERVER_GUARANTEE_UNAVAILABLE` | required capability 미지원 | false | +| `FILESERVER_POLICY_REVISION_UNAVAILABLE` | freeze된 policy/key revision 재생 불가 | false | +| `FILESERVER_INTEGRITY_FAILED` | size/checksum/manifest mismatch | false | +| `FILESERVER_PUBLISH_INDETERMINATE` | commit 여부 불명 | blind retry 금지 | +| `FILESERVER_REGENERATION_REQUIRED` | durable sealed source가 없어 application 판단 필요 | false | +| `FILESERVER_REGENERATION_CONFLICT` | prior state/revision/supersession 충돌 | false | +| `FILESERVER_CANCELLED` | deadline/shutdown/user cancel | 조건부 | +| `FILESERVER_CLEANUP_FAILED` | staging/retention cleanup 실패 | true | + +`retryable=true`는 같은 operation ID로 reconcile 또는 안전한 단계 retry가 가능하다는 뜻이다. +새 operation ID로 payload를 다시 쓰라는 뜻이 아니다. + +Outcome phase를 별도 값으로 둔다. + +```text +PRE_COMMIT_FAILED +PUBLISHED +INDETERMINATE +CONFLICT +ABORTED +QUARANTINED +REGENERATION_REQUIRED +SUPERSEDED +``` + +## 11. Publication 상태 머신 + +```mermaid +stateDiagram-v2 + [*] --> RESERVING + RESERVING --> STAGING + STAGING --> WRITING + WRITING --> SEALED + SEALED --> PUBLISHING + PUBLISHING --> COMMITTED_UNCONFIRMED: publish ACK + PUBLISHING --> INDETERMINATE: ACK lost + COMMITTED_UNCONFIRMED --> CONFIRMING + CONFIRMING --> PUBLISHED + CONFIRMING --> INDETERMINATE: stat or journal unavailable + CONFIRMING --> QUARANTINED: integrity mismatch + + RESERVING --> ABORTING + STAGING --> ABORTING + WRITING --> ABORTING + SEALED --> ABORTING + ABORTING --> ABORTED + ABORTING --> ORPHANED + + PUBLISHING --> INDETERMINATE + INDETERMINATE --> RECONCILING + RECONCILING --> PUBLISHED + RECONCILING --> ABORTED + RECONCILING --> QUARANTINED + RECONCILING --> REGENERATION_REQUIRED + REGENERATION_REQUIRED --> SUPERSEDED + + PUBLISHED --> DELETE_PENDING + DELETE_PENDING --> DELETED +``` + +Commit point: + +- `ATOMIC_RENAME`: final name으로의 atomic rename 성공 응답; +- `READY_MARKER`: data와 manifest 검증 후 marker의 exclusive publish 성공; +- SFTP `POSIX_RENAME`: negotiated extension rename 성공 응답; +- 응답을 잃었다면 commit 여부를 단정하지 않고 `INDETERMINATE`. + +Commit primitive가 성공해도 즉시 `PUBLISHED`가 아니다. 먼저 `COMMITTED_UNCONFIRMED`로 +전환하고 final stat, digest/manifest/marker, terminal control record를 확인한다. 이 confirm이 +실패하면 이미 생성됐을 수 있으므로 pre-commit failure로 되돌리지 않고 `INDETERMINATE`다. +내용 불일치가 증명되면 `QUARANTINED`다. + +`PUBLISHED`는 선택 protocol이 요구하는 file size, digest, manifest, marker와 terminal receipt +snapshot이 모두 확인됐을 때만 반환한다. 이후 HTTP 응답이나 caller DB 저장이 유실되더라도 +같은 operation ID의 재호출은 producer를 재실행하지 않고 이 terminal record에서 receipt를 +복원한다. + +### 11.1 Durable control plane과 source of truth + +Operation ID만으로 idempotency가 생기지 않는다. R2 destination은 payload namespace와 별도로 +provider-owned private control plane을 가져야 한다. + +```text +.ca-fileserver/ + operations/{operation-id-prefix}/{operation-id}.json + references/{file-id-prefix}/{file-id}.json + supersessions/{prior-operation-id}.json + staging/{operation-id}/... + quarantine/... +``` + +Operation journal v1 최소 필드: + +```text +journalSchemaVersion +operationId +requestIntentDigest +operationFingerprint +policySnapshotSchemaVersion +effectivePolicyRevision +effectivePolicyDigest +effectivePolicySnapshot +formatPolicyDigest +protectionPolicyDigest +retentionPolicyDigest +payloadKeyVersion +destinationId +sourceRevision +schemaDigest +publishCondition +fileId +referenceRoute +generatedRelativeLocator +publishedFileName +state +stateRevision +attemptId +coordinationMode +ownerInstanceId +heartbeatAt +stageRelativeLocator +sealedByteSize +sealedSha256 +manifestDigest +receiptSnapshot +createdAt +updatedAt +lastFailureCode +supersededByOperationId +``` + +`generatedRelativeLocator`와 staging locator는 adapter 내부 control data이며 receipt, metric, +일반 info log로 노출하지 않는다. Journal에는 raw row/cell, credential, absolute path, remote +URI를 저장하지 않는다. `effectivePolicySnapshot`은 canonical non-secret options만 포함하고 +key/credential은 logical ID와 version만 기록한다. + +`PublishedFileReference`는 versioned opaque route token과 random `fileId`로 구성하고 실제 +provider locator를 포함하지 않는다. Adapter는 route token으로 bounded destination을 찾고 +`references/{fileId}.json`을 조회한다. Reference index에는 operation ID, version, internal +relative locator, manifest digest만 저장한다. 따라서 inspect/transfer/delete가 directory +scan이나 caller path에 의존하지 않는다. + +`fileId`는 충분한 entropy의 CSPRNG 값이고 reference parser는 version, 길이, route allowlist를 +검증한다. 그러나 opaque/unguessable reference는 authorization token이 아니다. 어느 principal이 +inspect/transfer/delete할 수 있는지는 application use case와 inbound authorization이 +검증하며, fileserver는 tenant/user 권한을 추론하지 않는다. + +Control record update: + +1. 새 record를 private temp name으로 `CREATE_NEW`한다. +2. canonical encoding과 digest를 검증한다. +3. provider가 증명한 atomic replace 또는 marker protocol로 `stateRevision`을 전진시킨다. +4. required durability 수준에 맞춰 record와 directory/remote file을 sync한다. +5. 더 낮거나 중복된 revision은 무시하고 fingerprint가 다르면 conflict로 격리한다. + +Atomic record replace는 torn/partial control file 노출을 막을 뿐 compare-and-set이나 fencing이 +아니다. `stateRevision`만으로 stale writer를 막지 않는다. Record를 갱신할 writer의 +single-owner evidence가 없으면 multi-node destination은 global coordination guarantee를 +claim하지 않으며 required profile은 fail-fast한다. + +Journal은 진행 상태와 lookup의 durable evidence지만 remote payload와 한 transaction이 아니다. +Truth priority는 다음과 같다. + +```text +valid final payload + matching terminal manifest/marker + > terminal reference/operation record + > non-terminal journal + > in-memory registry +``` + +서로 모순되면 자동 성공/삭제하지 않고 reconciliation 또는 quarantine으로 전환한다. + +Provider별 control plane: + +- local/mounted: target의 private managed root 안에 두고 payload와 동일한 provider 보장으로 + 갱신한다. +- SFTP: remote private control directory 또는 pre-provisioned persistent local/shared control + volume 중 하나를 명시한다. +- `HANDOFF` destination이 sidecar/control file을 허용하지 않으면 persistent local/shared + control volume이 필수다. +- Ephemeral pod disk만 있는 SFTP destination은 restart 후 receipt 복원과 global + reconciliation을 보장할 수 없으므로 R2가 아니다. + +Final manifest와 journal은 상호 복구에 필요한 fingerprint, file ID, manifest digest를 +공유한다. Startup 전체 scan은 금지하고 direct operation/file ID lookup과 bounded background +reconciliation만 수행한다. + +## 12. 공통 staged publish protocol + +### 12.1 Plan + +1. destination binding과 provider effective capability를 조회한다. +2. request invariant, fingerprint, duplicate operation 상태를 확인한다. +3. required guarantee와 provider descriptor를 비교한다. +4. final name, staging name, marker/manifest plan을 생성한다. +5. quota/concurrency slot과 deadline budget을 획득한다. + +### 12.2 Stage + +1. target filesystem/remote namespace 내부 private staging에 + `..part`를 exclusive-create한다. +2. Temp와 final이 같은 filesystem/fsid/remote root인지 검증한다. +3. 제한 권한을 creation 시점에 설정한다. +4. CSV encoder를 bounded byte buffer 위에 구성한다. +5. row마다 schema, column count, formula, control, size, deadline을 검사한다. +6. SHA-256, byte count, row count를 streaming 중 계산한다. +7. 선택된 compression/encryption transform도 streaming pipeline 안에서 처리한다. + +```text +typed cells + -> CSV encoder + -> optional compression + -> optional encryption/signature + -> digest/count/limit + -> provider staging sink +``` + +Baseline transform은 `NONE`이다. GZIP, PGP encryption/signature는 named protection profile로 +열어두되 실제 reference implementation과 key-rotation test가 생기기 전에는 R0로 표시한다. + +### 12.3 Seal + +1. Encoder를 flush하고 malformed/unmappable character를 `REPORT` 정책으로 검사한다. +2. Channel/remote handle을 close하기 전에 요구되는 file sync를 수행한다. +3. Final byte size와 digest를 확정한다. +4. Frozen policy와 final locator를 포함한 private manifest draft를 canonical JSON으로 만든다. +5. Sealed digest와 manifest draft를 operation journal에 durable하게 기록한다. +6. Provider가 지원하면 staging content를 다시 stat/read-back한다. +7. Permission과 regular-file/no-link 조건을 재확인한다. + +### 12.4 Publish + +선택 가능한 protocol: + +| Protocol | Commit point | 사용 조건 | +| --- | --- | --- | +| `UNIQUE_ATOMIC_CREATE` | staged inode -> unique final hard-link create | same filesystem, hard-link support proven | +| `ATOMIC_REPLACE` | temp -> existing final atomic replace | provider-specific replace semantics proven | +| `READY_MARKER` | verified marker exclusive publish | consumer가 marker를 이해함 | +| `SFTP_POSIX_RENAME` | negotiated posix rename | extension과 same remote filesystem | +| `DIRECT_FINAL_WRITE` | close | prod 금지 | + +Required protocol이 불가능하면 실패한다. Copy+delete, remove+rename 같은 fallback으로 guarantee를 +낮추지 않는다. Publish primitive의 성공 응답은 `COMMITTED_UNCONFIRMED`이며 아직 caller에게 +receipt를 반환하지 않는다. + +### 12.5 Confirm + +1. Final stat의 type, size, version을 확인한다. +2. 가능한 provider는 digest를 read-back 검증한다. +3. Marker/manifest가 final artifact를 정확히 가리키는지 확인한다. +4. Reference index와 achieved guarantee를 포함한 receipt snapshot을 만든다. +5. Operation journal을 terminal `PUBLISHED`로 durable하게 전진시킨다. +6. quota slot을 반환하고 receipt를 반환한다. + +### 12.6 Failure + +- Publish 이전: staging abort/delete, 실패하면 orphan 등록; +- Publish 요청 전송 후 응답 유실: `INDETERMINATE`; +- Publish ACK 뒤 final stat/control record update 실패: `INDETERMINATE`; +- Final exists + expected digest match: PUBLISHED로 reconcile; +- Final exists + digest/fingerprint mismatch: conflict/quarantine; +- Marker exists + data 없음: integrity incident; +- Data exists + marker 없음: marker protocol에서는 unpublished residue; +- Cleanup은 원래 source exception을 덮지 않는다. + +### 12.7 Artifact publication ordering + +다음 artifact를 구분한다. + +```text +D-stage staged payload +D-final consumer-visible payload +J-sealed durable non-terminal operation journal + manifest draft +M-private provider control-plane terminal manifest +M-public optional consumer-facing sidecar manifest +K-ready consumer-aware ready marker +R-ref opaque reference index +J-final terminal operation journal + receipt snapshot +``` + +모든 protocol에서 `J-sealed`가 publish primitive보다 먼저 durable해야 한다. 그래야 data commit +후 process가 죽어도 producer를 다시 실행하지 않고 final locator, expected size/digest, +fingerprint, frozen policy로 복구할 수 있다. + +| Protocol | 순서 | Consumer commit point | +| --- | --- | --- | +| `UNIQUE_ATOMIC_CREATE` | D-stage sync → J-sealed → data atomic hard-link create → final confirm → M-private → R-ref → J-final | data link | +| `ATOMIC_REPLACE` | D-stage sync + expected version → J-sealed → proven atomic replace → final confirm → M-private → R-ref → J-final | data replace | +| `SFTP_POSIX_RENAME` | local spool seal/sync → J-sealed → remote part upload/(remote fsync) → POSIX rename → remote confirm → M-private → R-ref → J-final | POSIX rename | +| `READY_MARKER` | D-stage + M-public stage/sync → J-sealed → versioned data/manifest publish → verify → K-ready exclusive publish → confirm → M-private → R-ref → J-final | ready marker | + +`M-private`, `R-ref`, `J-final`은 각각 temp+verified atomic replace/marker를 사용하며 마지막 +`J-final`이 durable하기 전에는 port가 receipt를 반환하지 않는다. 이 세 record가 provider와 +원자 transaction을 이루는 것은 아니다. 각 write는 idempotent하고 operation ID, +state revision, manifest digest로 재구성 가능해야 한다. + +Single-file atomic rename protocol에서 `M-public`은 commit 구성요소가 될 수 없다. Data와 public +manifest를 하나의 consumer contract로 원자 공개해야 하면 `READY_MARKER` 또는 provider가 +실패 시험으로 증명한 atomic directory/bundle publish를 선택한다. Data rename 뒤 sidecar를 +추가하면서 “둘이 atomic”이라고 주장하지 않는다. + +Crash 판정: + +| 관찰 상태 | 복구 | +| --- | --- | +| D-stage만 있고 J-sealed 없음 | never committed; bounded abort/reap | +| J-sealed + D-stage, final/marker 없음 | sealed payload로 publish resume 또는 abort; producer 0회 | +| D-final matches J-sealed, M-private/R-ref/J-final 일부 없음 | `COMMITTED_UNCONFIRMED`; missing control record 재구성 | +| D-final digest가 J-sealed와 다름 | conflict/quarantine; overwrite 금지 | +| K-ready 존재 + matching data/public manifest | committed; private control records 재구성 | +| data/public manifest 존재 + K-ready 없음 | marker protocol에서 unpublished residue | +| R-ref 존재 + J-final 없음 | final/manifest 확인 후 J-final 복구 | +| J-final 존재 + final/marker 없음 | integrity incident; 성공으로 반환 금지 | +| publish request ACK 유실 | `INDETERMINATE`; 위 evidence로 reconcile | + +Public manifest가 필요 없는 single-file consumer도 mandatory private manifest/control record는 +유지한다. `HANDOFF` partner root가 private sidecar를 허용하지 않으면 별도 persistent control +volume에 둔다. + +## 13. Guarantee 모델 + +하나의 `productionReady=true` boolean으로 provider를 표현하지 않는다. + +### 13.1 Descriptor + +`FileServerProviderDescriptor`는 configured claim이 아니라 effective claim이다. + +| 축 | 값 예시 | +| --- | --- | +| Provider | `LOCAL_POSIX`, `SHARED_POSIX`, `SFTP` | +| Visibility scope | `NODE`, `CLUSTER`, `REMOTE_ENDPOINT` | +| Streaming | write/read/range/resume | +| Publish primitive | atomic rename, hardlink publish, marker, remote rename | +| Atomicity | atomic unique, no-replace, replace | +| Durability | file sync, directory sync, remote fsync, NFS stable commit | +| Consistency | local immediate, close-to-open, remote-server | +| Concurrency | none, advisory, leased | +| Fencing | supported/unsupported | +| Security | secure directory, no-follow, server chroot | +| Permissions | POSIX/ACL/remote chmod | +| Capacity | usable-space observation, native quota | +| Recovery | stage list/delete, reconcile, read-back digest | +| Control plane | durable operation index, reference lookup, atomic revision | +| Coordination | process-local, provider reservation, external required | +| Limits | file/chunk/concurrency/queue/in-flight/request handles/timeouts | + +각 항목은 다음 상태와 증거를 함께 가진다. + +```text +SupportStatus: + SUPPORTED + UNSUPPORTED + UNVERIFIABLE + +Evidence: + SPEC + NEGOTIATED_EXTENSION + ACTIVE_PROBE + OPERATOR_ATTESTED + FAILURE_TESTED +``` + +API가 존재한다는 것과 보장이 검증됐다는 것은 다르다. + +### 13.2 분리해야 하는 보장 + +- Atomic visibility: reader가 partial final name을 보지 않는가? +- Crash durability: host/server crash 뒤 data와 name이 남는가? +- Immediate cross-node visibility: 다른 node가 즉시 관찰하는가? +- Create-only atomicity: 같은 name 경쟁에서 정확히 한 writer만 성공하는가? +- Replace atomicity: old 또는 new만 보이고 중간 상태가 없는가? +- Outcome certainty: timeout 뒤 commit 여부를 알아낼 수 있는가? +- Integrity: provider에 저장된 bytes가 digest와 일치하는가? + +한 축의 성공을 다른 축의 증거로 사용하지 않는다. + +## 14. Local persistent filesystem provider + +### 14.1 용도 + +- single-node 또는 node-attached persistent volume; +- application이 소유하는 private directory; +- generated/versioned file publication; +- 동일 node에서 소비하거나 별도 delivery가 있는 경우. + +Container ephemeral directory를 production persistent filesystem으로 분류하지 않는다. + +### 14.2 Startup 조건 + +- prod root는 absolute path; +- `autoCreate=false`; +- directory가 미리 존재; +- root와 ancestor가 symlink가 아님; +- expected owner/group/mode; +- world/group writable 정책 위반 없음; +- expected `FileStore`/device/mount sentinel 일치; +- staging과 final directory가 동일 `FileStore`; +- minimum usable-space watermark; +- required `SecureDirectoryStream`/atomic move capability probe. + +Mount가 빠졌을 때 underlying local directory를 자동 생성해 성공하면 안 된다. + +### 14.3 Write와 durability + +- temp file `CREATE_NEW`; +- creation attribute로 기본 0600; +- publish 직전 target policy에 맞춰 0640 등 설정; +- `FileChannel.force(true)`로 file data/metadata sync; +- same-filesystem atomic move; +- required profile에서 directory sync 수행. + +Java `FileChannel.force`는 local storage device에만 강한 저장장치 기록 보장을 주고 non-local +device에는 보장하지 않는다. Directory fsync는 Java portability가 낮으므로: + +- portable JDK provider는 `FILE_SYNC`까지만 claim; +- Linux-specific tested implementation만 `FILE_AND_DIRECTORY_SYNC` claim; +- directory sync가 요구되는데 구현이 없으면 fail-fast; +- site replication과 backup은 별도 operation guarantee. + +### 14.4 Path security + +Strict mode: + +- open directory-relative operation; +- `SecureDirectoryStream` 지원 시 이를 사용; +- 모든 target/staging operation은 relative single-segment name; +- `NOFOLLOW_LINKS` attribute/stat; +- final과 temp가 regular file인지 확인; +- untrusted user가 root에 entry를 만들 수 없도록 permission boundary. + +`toRealPath` 선검사 후 일반 open만 수행하는 것은 check/open 사이 race를 제거하지 못한다. +Provider가 secure relative operation을 지원하지 않고 root에 untrusted writer가 있으면 R2 +strict mode를 claim할 수 없다. + +### 14.5 Collision + +Portable Java `ATOMIC_MOVE`는 target이 이미 존재할 때 replace/fail이 implementation-specific다. +따라서 기본은 server-generated unique/versioned final name이다. + +- `CREATE_UNIQUE`: UUID/ULID 기반 final name, collision 시 hard fail; +- `CREATE_IF_ABSENT` stable name: native no-replace/hardlink primitive가 증명된 provider만; +- `REPLACE_IF_VERSION`: expected version 확인 + provider-specific atomic replace evidence; +- unconditional replace: legacy profile만; +- append: 금지. + +## 15. Mounted/shared filesystem provider + +Mounted provider는 local provider class의 별칭이 아니다. 동일 JDK API를 사용하더라도 +guarantee와 운영 검증이 다르다. + +### 15.1 공통 조건 + +- mount는 IaC가 pre-provision; +- application auto-mount/auto-create 금지; +- expected mount sentinel과 `FileStore` identity; +- stage와 final은 target mount 내부; +- bounded concurrency, queue, in-flight bytes; +- blocking/hung IO timeout과 shutdown 정책; +- multi-client integration evidence. + +Virtual thread는 blocked platform-thread 비용을 줄일 수 있지만 filesystem 또는 server 부하, +queue, byte pressure를 제한하지 않는다. Semaphore와 byte budget은 별도다. + +### 15.2 NFS semantics + +NFSv4 rename은 client 관점에서 atomic이며 source/target directory가 같은 server filesystem +이어야 한다. 이것이 의미하는 것은 partial final name을 피할 수 있다는 것이지 다음을 +의미하지 않는다. + +- 즉시 cluster-wide directory visibility; +- strong cache coherence; +- fenced lock; +- application-level exactly-once; +- Java `force`가 local disk와 같은 durability를 제공함. + +NFS profile 규칙: + +- immutable/versioned file 우선; +- reader는 close/reopen과 marker/manifest contract 사용; +- directory polling을 authoritative event source로 사용하지 않음; +- final receipt나 DB/message가 discoverability source; +- `FileLock`/NFS lease를 correctness의 단독 근거로 사용하지 않음; +- 동일 final name multi-writer와 append 금지; +- server `sync` export/backend stable storage는 operator attestation과 failure test 필요; +- `async` export는 strong durability profile에서 거부. + +### 15.3 NFS effective guarantee + +| Guarantee | 기본 판정 | +| --- | --- | +| Streaming write | SUPPORTED | +| Same-fsid rename visibility | SPEC + ACTIVE_PROBE | +| Immediate other-node discovery | UNVERIFIABLE | +| File sync from Java | UNVERIFIABLE | +| Server stable commit | OPERATOR_ATTESTED + FAILURE_TESTED 필요 | +| File lock fencing | UNSUPPORTED | +| Multi-node immutable key | SUPPORTED | +| Stable-name multi-writer replace | external coordination 필요 | + +### 15.4 Mount loss + +다음 상태를 구분한다. + +- mount unavailable; +- stale file handle; +- mount identity changed; +- underlying local mountpoint visible; +- read-only remount; +- free space/inode exhaustion; +- server reboot/cache delay. + +Sentinel mismatch나 mount identity change는 새 publication을 fail-closed하고 readiness를 내린다. +기존 staging을 자동 삭제하지 않는다. + +## 16. SFTP provider + +### 16.1 구현 선택 + +초기 구현은 fileserver leaf 안에서 Spring Integration SFTP의 programmatic API를 사용한다. + +- `DefaultSftpSessionFactory`; +- bounded `CachingSessionFactory`; +- `RemoteFileTemplate.execute` 또는 `executeWithClient`; +- underlying Apache MINA `SftpClient`로 extension negotiation. + +Message channel, SpEL path expression, outbound adapter를 application API로 노출하지 않는다. +Boot BOM이 관리하는 compatible `spring-integration-sftp` version을 사용하며 SDK type은 leaf 밖으로 +나가지 않는다. + +### 16.2 Security + +- host key verification 필수; +- known-hosts 또는 pinned fingerprint/host CA; +- `allowUnknownKeys=false`; +- TOFU와 changed-key 자동 수락 금지; +- private key/agent 또는 secret-source credential; +- password/private-key literal을 YAML/log에 저장하지 않음; +- key rotation은 old/new dual trust window와 audit; +- modern cipher/KEX/MAC allowlist; +- remote account는 chroot 또는 restricted root; +- root-owned/non-group-writable chroot hierarchy; +- writable child만 service account에 허용; +- remote path는 destination binding의 fixed relative segments만 사용. + +#### 16.2.1 Secret material resolution과 rotation + +YAML의 `private-key-secret-ref`와 `known-hosts-secret-ref`는 문자열 치환용 secret 값이 아니라 +등록된 logical reference다. 다음 adapter-private bootstrap SPI를 fileserver leaf가 소유한다. + +```text +FileServerSecretMaterialProvider + acquire(SecretReference) -> SecretMaterialLease + +SecretMaterialLease + version + expiresAt + readOnlyBytes/readOnlyChars + close() +``` + +- 이 SPI는 application port가 아니며 `application-core`에 노출하지 않는다. +- `app-bootstrap`은 허용된 config-tree/file-mounted secret source 또는 명시적으로 설치한 + runtime provider를 조합한다. +- Fileserver는 sibling secret/cache adapter를 직접 호출하지 않는다. +- 여러 adapter가 같은 seam을 실제로 필요로 할 때만 별도 skeleton-wide secret contract + ADR을 작성하며, 이 설계에서 `shared-contract`를 선제 확장하지 않는다. + +`SecretReference`는 bounded registry ID이고 raw file path, URI, environment variable name, +secret value를 허용하지 않는다. Config-tree 구현은 bootstrap이 고정한 private root 아래에서 +no-follow/owner/mode를 검증해 읽는다. SDK가 임시 `Path`만 받는 경우 fileserver가 0600 temp +file의 생성·삭제를 소유한다. + +Lifecycle: + +1. inactive destination은 secret을 resolve하지 않는다. +2. required destination은 startup/readiness에서 reference 존재와 trust material parse를 + 확인하되 private key 내용을 log하지 않는다. +3. 새 physical SSH connection을 만들 때 current secret/trust version을 lease한다. +4. Session cache entry는 credential version과 trust version으로 표기한다. +5. TTL/rotation signal에서 old-version session을 새 대여에서 제외하고 bounded drain 후 + 폐기한다. +6. 진행 중 upload를 강제 중단할지는 security policy가 정하며, 중단 시 publish outcome을 + reconcile한다. +7. Lease close 시 mutable buffer/temp file을 best-effort zeroize/delete한다. + +JVM/SDK가 복사한 key material의 완전한 zeroization은 보장할 수 없다. Heap dump, crash dump, +debug log 접근 통제와 process isolation도 운영 통제에 포함한다. Reference resolve 실패, +만료, rotation mismatch는 credential literal fallback 없이 readiness/error로 드러낸다. + +### 16.3 Pool과 timeout + +명시적으로 제한한다. + +- max physical SSH connections; +- max SFTP sessions/channels; +- session cache size; +- session acquisition timeout; +- connect timeout; +- authentication timeout; +- socket/read/write idle timeout; +- overall operation deadline; +- max outstanding requests; +- max packet/read/write size; +- keepalive와 stale-session test; +- shutdown drain timeout. + +Spring Integration의 unbounded cache와 사실상 infinite wait default를 그대로 사용하지 않는다. + +### 16.4 Selected staging strategy + +R2 SFTP는 producer를 remote network retry 때문에 재실행하지 않도록 local secure spool을 기본으로 +사용한다. + +```text +row producer + -> bounded local encrypted/secure spool + digest + -> remote .part upload + -> remote stat/fsync if supported + -> negotiated publish + -> local spool cleanup +``` + +Trade-off: + +- heap은 bounded; +- local disk capacity는 추가 필요; +- network retry 시 source query를 반복하지 않음; +- sensitive file은 local spool encryption/protection profile 필요; +- spool reaper와 quota가 필수. + +Direct pipe streaming은 retry와 failure isolation이 약하므로 R1 opt-in으로만 둔다. + +### 16.5 Extension handshake + +다음 extension은 존재한다고 가정하지 않고 연결마다 협상한다. + +| Extension | 제공 가능 보장 | +| --- | --- | +| `posix-rename@openssh.com` | POSIX rename semantics | +| `fsync@openssh.com` | open remote file의 server fsync | +| `statvfs@openssh.com` | capacity observation | +| `limits@openssh.com` | packet/request/handle 제한 | +| `hardlink@openssh.com` | 검증된 same-fs hardlink publication 후보 | + +Base SFTP v3 rename은 atomicity를 약속하지 않는다. `posix-rename`이 없고 destination이 +atomic rename을 요구하면 startup/readiness 실패다. + +### 16.6 SFTP durability 한계 + +`fsync@openssh.com`은 file handle만 sync한다. Directory-entry fsync extension은 없으므로 +SFTP provider는 `FILE_AND_DIRECTORY_CRASH_DURABLE`을 claim하지 않는다. + +가능한 receipt: + +```text +REMOTE_FILE_SYNCED +REMOTE_RENAME_ACKNOWLEDGED +REMOTE_NAME_DURABILITY_UNVERIFIED +``` + +### 16.7 Unknown outcome + +다음은 `FAILED`가 아니라 `INDETERMINATE`다. + +- rename request 이후 connection reset; +- server가 commit 후 response 전 crash; +- timeout이 publish request와 겹침; +- client shutdown 중 remote ACK 유실. + +Reconcile: + +1. 같은 operation ID의 final/part/marker를 stat; +2. final size와 manifest digest 비교; +3. 가능한 경우 read-back digest; +4. final match면 기존 receipt 복원; +5. part만 있으면 resume 조건 또는 cleanup 판단; +6. final mismatch면 overwrite하지 않고 conflict/quarantine. + +### 16.8 Resume + +Resume는 기본 off다. 다음 조건을 모두 만족할 때만 가능하다. + +- source spool이 immutable/repeatable; +- expected total size와 digest가 있음; +- remote partial prefix가 동일 source prefix임을 검증; +- server offset write semantics가 검증됨; +- operation ID와 staging name이 동일; +- overall deadline 안에서 재개. + +단순 remote size만 보고 offset부터 이어 쓰지 않는다. + +## 17. CSV format와 spreadsheet safety + +### 17.1 Named profile + +임의 delimiter option을 request마다 받지 않고 named profile을 등록한다. + +Baseline: + +- `CSV_RFC4180_MACHINE`; +- `CSV_SPREADSHEET_SAFE`. + +Partner dialect는 fork가 별도 profile ID로 추가한다. + +### 17.2 RFC 4180 machine profile + +- media type `text/csv`; +- UTF-8; +- CRLF record separator; +- optional header 여부 명시; +- 모든 row는 schema와 같은 field count; +- comma separator; +- double-quote escaping; +- null과 empty string 정책 분리; +- BOM off가 기본; +- malformed/unmappable encoding은 fail; +- NUL 및 금지 control character 정책; +- final line break 정책 명시. + +현재 구현의 LF-only 출력을 그대로 RFC 4180이라고 부르지 않는다. + +### 17.3 Spreadsheet formula + +CSV quoting은 formula execution을 막지 않는다. 모든 column은 다음 중 하나를 선택한다. + +| Policy | 동작 | +| --- | --- | +| `REJECT_FORMULA_LIKE` | formula-like value면 export 실패 | +| `SPREADSHEET_TEXT_PREFIX` | named profile의 명시적 literalization | +| `PRESERVE_MACHINE_DATA` | 변환하지 않으며 spreadsheet용 아님 | +| `TRUSTED_VALUE` | 생성값에만 제한적으로 사용 | + +판정은 leading whitespace/control normalization 후 `=`, `+`, `-`, `@`, tab, CR/LF와 +separator/quote를 통한 새 cell 형성 가능성을 검사한다. + +범용으로 모든 spreadsheet와 machine re-import에 동시에 안전한 변환은 없다. 따라서: + +- machine용과 human-spreadsheet용 profile을 분리; +- 변환은 schema에 명시; +- 변환된 cell count를 receipt/metric에 기록; +- 원문 cell을 log/audit에 남기지 않음; +- round-trip data preservation을 요구하면 formula-like cell을 reject. + +### 17.4 Limits + +Binding과 profile이 제한한다. + +- max columns; +- max rows; +- max cell characters; +- max encoded cell bytes; +- max row bytes; +- max total output bytes; +- max header bytes; +- max multiline cell lines; +- max operation duration. + +Limit은 사전 추정만 하지 않고 streaming 중 byte counter로 강제한다. + +### 17.5 CSV test oracle + +- golden byte snapshot; +- RFC edge case; +- comma/quote/CR/LF/CRLF; +- emoji와 multi-byte chunk boundary; +- unpaired surrogate/encoding failure; +- null vs empty; +- row width mismatch; +- duplicate/blank header policy; +- formula vector와 leading whitespace; +- output limit 직전/초과; +- locale/timezone 독립. + +## 18. File name, path, permission security + +### 18.1 Logical name + +`logicalFileName`은 path가 아니다. + +- Unicode NFC normalization; +- single logical stem; +- max UTF-8 bytes; +- control/NUL/separator 금지; +- `.`/`..` 금지; +- Windows device name/drive/UNC grammar 금지; +- extension은 format/protection profile이 생성; +- physical final name은 constrained template이 생성. + +예: + +```text +{logicalStem}-{utcDate}-{operationId}.csv +``` + +Arbitrary SpEL과 caller-supplied subdirectory expression을 사용하지 않는다. + +### 18.2 Directory binding + +Directory는 configuration에서만 온다. + +- normalized fixed relative segments; +- no `..`; +- no absolute override; +- provider root 바깥으로 나갈 수 없음; +- segment별 max length; +- startup 시 resolved root 검증. + +### 18.3 TOCTOU와 links + +Unsafe pattern: + +```text +normalize -> startsWith -> later open +``` + +Safe baseline: + +- private trusted root; +- secure directory-relative open/move; +- `CREATE_NEW`; +- no-follow attribute checks; +- target type regular-file 검증; +- unpredictable staging name; +- strict directory ownership/permissions. + +Hardlink 공격은 portable Java stat만으로 완전히 막기 어렵다. Root에 untrusted writer가 없다는 +permission precondition이 핵심이다. + +### 18.4 Permission + +- staging file: owner read/write only; +- final file: binding의 explicit owner/group/mode; +- directory: traversal 가능한 최소 권한; +- umask에만 의존하지 않고 creation attribute/chmod 검증; +- SFTP는 publish 전에 remote permission 설정; +- ACL/POSIX 미지원 provider는 required permission guarantee를 claim하지 않음. + +## 19. Collision, concurrency, idempotency + +### 19.1 Publication mode + +```text +CREATE_UNIQUE +CREATE_IF_ABSENT +REPLACE_IF_VERSION +``` + +`REPLACE_ALWAYS`는 dev/legacy exception이고 `APPEND`는 baseline에서 금지한다. + +### 19.2 Same operation concurrency + +한 process: + +- in-memory bounded operation registry로 같은 process의 concurrent duplicate producer 실행 방지; +- terminal result cache는 optimization일 뿐 source of truth가 아님. + +Multi-node: + +- shared filesystem의 exclusive reservation과 stale-owner recovery가 failure test로 검증됐으면 + operation claim `CREATE_NEW`; +- SFTP에는 portable fenced claim이 없으므로 application export-job store가 단일 worker claim; +- fileserver는 DB/Redis adapter를 직접 호출하지 않음; +- provider descriptor가 `EXTERNAL_OPERATION_COORDINATION_REQUIRED`를 표시. + +보장 수준을 구분한다. + +| 보장 | 필요 evidence | +| --- | --- | +| accepted attempt 내부 producer at-most-once | 모든 provider baseline | +| process 내부 concurrent single-flight | in-memory registry | +| crash 뒤 sealed payload 재사용 | durable journal + persistent spool/staging | +| 여러 node에서 producer global at-most-once | durable single-owner claim과 실패 후 ownership 규칙 | +| 하나의 logical final로 수렴 | stable source revision + immutable operation-derived name + reconcile | +| stable name conditional replace | provider CAS/fencing primitive 또는 authoritative external metadata | + +외부 job claim이 lease 만료 뒤 old worker를 fence하지 못하면 global at-most-once로 기록하지 +않는다. 이 경우 두 producer가 실행될 수 있으므로 source revision은 repeatable해야 하고, +동일 operation의 sealed digest가 다르면 어느 쪽도 overwrite하지 않고 quarantine한다. + +SFTP local spool을 seal한 뒤에는 network retry가 producer를 재실행하지 않는다. 그러나 pod +장애로 ephemeral spool이 사라졌다면 기존 attempt를 재생할 수 없다. Persistent spool/control +plane이 없으면 operation은 `REGENERATION_REQUIRED`로 끝나며 global at-most-once 또는 +restart-safe idempotency를 claim하지 않는다. R2 required destination은 이 topology를 +fail-fast한다. + +### 19.3 Stable final name + +여러 pod가 같은 stable name을 교체해야 하면 fileserver lock만으로 correctness를 만들지 않는다. + +- immutable version file을 먼저 publish; +- current pointer/marker를 expected-version 조건으로 교체; +- provider가 atomic conditional replace를 증명하지 못하면 external authoritative metadata 사용; +- reader가 generation/version을 검증. + +### 19.4 FileLock + +Java `FileLock`은 advisory로 취급하며: + +- 같은 JVM thread coordination 용도가 아님; +- NFS에서는 lease/failover 한계; +- fencing token을 제공하지 않음; +- correctness의 단독 근거가 아님. + +## 20. Transaction과 async export workflow + +Database와 fileserver는 하나의 transaction이 아니다. + +### 20.1 금지 shape + +```text +DB write transaction begin + -> file publish + -> DB save +commit +``` + +DB rollback이 이미 published file을 되돌리지 못한다. + +### 20.2 권장 large export + +```text +REQUESTED export job commit + -> worker claims job + -> stable snapshot/cursor pages + -> fileserver publish(operationId) + -> receipt persist + -> COMPLETED +``` + +규칙: + +- Fileserver는 job table과 scheduler를 소유하지 않음; +- application이 retry/cancel/authorization/source snapshot을 소유; +- fileserver는 one publish attempt와 reconcile을 소유; +- page query는 짧은 read transaction; +- source revision이 변하면 same operation fingerprint conflict; +- HTTP request thread에서 무제한 대용량 export를 실행하지 않음. + +### 20.3 DB command와 file delivery + +File delivery가 command 결과에 필수면: + +- business transaction에 delivery intent/outbox/job을 기록; +- commit 후 worker가 file publish; +- 실패는 retry/terminal compensation; +- downstream acknowledgement가 필요하면 별도 inbound receipt use case. + +## 21. Quota, backpressure, timeout, shutdown + +### 21.1 Resource budgets + +Per binding/provider: + +- max active publications; +- max queued publications; +- max in-flight buffer bytes; +- max local spool bytes; +- max file bytes; +- max rows/columns/cell bytes; +- min usable-space watermark; +- max staging artifacts/bytes; +- max remote sessions/handles/outstanding requests. + +Virtual thread를 사용해도 이 budget은 제거되지 않는다. + +### 21.2 Capacity observation 한계 + +`FileStore.getUsableSpace`와 SFTP `statvfs`는 관찰값이지 reservation이 아니다. 다른 process와 +node가 동시에 쓸 수 있다. + +- Application은 per-operation byte limit을 강제; +- native user/group/project quota가 있으면 operator capability로 기록; +- logical tenant quota가 필요하면 external reservation ledger가 필요; +- disk low watermark는 새 publish를 거부; +- pressure 상황에서 unknown final을 임의 삭제하지 않음. + +### 21.3 Timeout 분리 + +```text +queueAcquireTimeout +sessionAcquireTimeout +connectTimeout +authenticationTimeout +idleReadWriteTimeout +contentProductionDeadline (cooperative) +publishCommitTimeout +overallOperationDeadline +shutdownDrainTimeout +``` + +Timeout 뒤 underlying local/NFS/SFTP IO가 즉시 중단됐다고 가정하지 않는다. Publish 단계와 겹치면 +`INDETERMINATE`로 전환한다. + +Timeout guarantee를 capability에 기록한다. + +- queue/session/connect/provider IO: 해당 client가 제공하는 cancel/close와 deadline으로 강제; +- content production: `sink.write/checkpoint`와 thread interruption 기반 cooperative deadline; +- upstream DB/HTTP query: 그 port/client의 statement/request timeout이 별도로 필요; +- arbitrary producer code: Java thread를 안전하게 강제 종료할 수 없으므로 hard timeout을 + 주장하지 않음; +- hard wall-clock isolation이 필수인 대규모 export: 별도 worker process/job을 종료한 뒤 + fileserver staging을 reconcile. + +### 21.4 Cancellation + +- row sink가 deadline/cancel을 확인; +- local channel은 interrupt/close; +- SFTP session/channel은 cancel 시 dirty/close; +- staging은 commit 전이면 abort; +- publish commit이 시작됐으면 결과 reconcile; +- cancellation 결과도 operation journal에 남김. + +### 21.5 Graceful shutdown + +1. 새 publication 접수 중단; +2. queued request reject; +3. active stage를 bounded drain; +4. commit phase는 atomic operation을 마치거나 INDETERMINATE 기록; +5. SFTP pool close; +6. 남은 staging은 다음 startup reconciliation 대상. + +## 22. Manifest, marker, retention, reconciliation + +### 22.1 Manifest v1 + +Canonical JSON: + +```text +manifestSchemaVersion +operationId +requestIntentDigest +operationFingerprint +effectivePolicyRevision +effectivePolicyDigest +fileReference +fileId +fileVersion +destinationId +logicalFileName +publishedFileName +sourceRevision +schemaId +exportSchemaVersion +schemaDigest +publishCondition +formatProfileId +protectionProfileId +protectionPolicyDigest +payloadKeyVersion +retentionPolicyDigest +byteSize +rowCount +columnCount +sha256 +publicationGuarantee +durabilityGuarantee +createdAt +publishedAt +retentionClassId +formulaMitigatedCount +``` + +Provider-private terminal manifest에는 복구용 `generatedRelativeLocator`와 operation journal +revision을 추가할 수 있다. 외부 consumer가 보는 public manifest와 private control manifest를 +분리하며, public manifest에는 internal locator를 넣지 않는다. + +포함 금지: + +- absolute/remote path; +- credential; +- raw row/cell; +- raw tenant/user ID; +- secret key ID를 넘어선 key material. + +Checksum은 accidental corruption 검출이지 authenticity가 아니다. Shared directory를 신뢰할 수 +없으면 signed/HMAC manifest protection profile이 필요하다. + +### 22.2 Marker protocol + +```text +version/data.csv +version/manifest.json +version/_SUCCESS +``` + +`_SUCCESS`가 manifest digest를 담고 마지막 commit point가 된다. + +Marker protocol은 marker를 이해하는 consumer에게만 atomic publication이다. 외부 시스템이 +단순히 `*.csv`를 scan하면 marker profile을 선택할 수 없다. + +### 22.3 Ownership mode + +```text +HANDOFF +MANAGED +``` + +- `HANDOFF`: published final은 외부 consumer 소유로 간주하고 auto-delete하지 않음; +- `MANAGED`: manifest/ref/version이 fileserver 소유임을 증명하는 artifact만 retention 적용. + +두 mode 모두 staging cleanup은 수행할 수 있다. + +### 22.4 Reaper + +Default는 `REPORT_ONLY`. + +1. bounded batch로 staging manifest 조회; +2. operation heartbeat/deadline + clock-skew grace; +3. active operation 보호; +4. stale candidate를 `.reap` 또는 provider claim으로 이동; +5. final/marker/manifest 재확인; +6. 알려진 fileserver-owned entry만 no-follow delete; +7. unknown/malformed entry quarantine 또는 report; +8. delete rate/duration limit; +9. audit와 metric 기록. + +Atomic cleanup claim이 없는 multi-node provider: + +- single maintenance runner를 deployment가 보장하거나; +- application composition이 leadership을 제공하거나; +- report-only로 제한. + +Fileserver가 Redis/JDBC lock adapter를 직접 의존하지 않는다. + +### 22.5 Reconciliation cases + +| 관찰 상태 | 기본 판정 | +| --- | --- | +| final + matching manifest/digest | PUBLISHED 복원 | +| final + digest mismatch | QUARANTINED/incident | +| temp only, active lease | 유지 | +| stale temp only | abort/reap 후보 | +| final data, marker 없음 | marker protocol에서 unpublished | +| marker, data 없음 | integrity incident | +| manifest, data 없음 | integrity incident | +| unknown external file | 보존/report | +| newer unknown manifest schema | 보존/quarantine | +| SFTP part + no final | resume 조건 또는 cleanup | +| SFTP final + ACK lost | stat/digest 후 PUBLISHED 복원 | + +### 22.6 Retention delete + +```text +ELIGIBLE + -> DELETE_PENDING/tombstone + -> provider delete + -> DELETED audit +``` + +- expected version; +- legal hold; +- retention grace; +- unknown schema 보존; +- delete failure retry; +- no recursive delete of base/root; +- target resolution은 opaque reference only. + +## 23. Configuration design + +### 23.1 Activation SSOT + +Binding map이 activation SSOT다. Binding이 없으면 capability는 inactive다. + +```yaml +ca-skeleton: + fileserver: + destinations: + worklog-export: + required: true + policy-revision: worklog-export-v1 + provider-ref: mounted-primary + directory: outbound/worklog + format-profile-ref: csv-machine + publication: + protocol: unique-atomic-rename + collision: create-unique + required-visibility: atomic-final-name + required-durability: file-sync + ownership: managed + retention-class-ref: export-7d + protection-profile-ref: internal + limits: + max-file-size: 1GB + max-rows: 5000000 + max-columns: 100 + max-cell-size: 1MB + max-duration: 30m + + providers: + mounted-primary: + type: mounted + root-directory: ${APP_FILESERVER_PRIMARY_ROOT} + auto-create: false + mount-sentinel: .ca-fileserver-mount + expected-mount-id: ${APP_FILESERVER_PRIMARY_MOUNT_ID} + min-usable-space: 10GB + max-concurrent-publications: 4 + max-queued-publications: 16 + control-plane: + mode: target-private + directory: .ca-fileserver + + partner-sftp: + type: sftp + host: ${APP_FILESERVER_SFTP_HOST} + port: 22 + username: ${APP_FILESERVER_SFTP_USERNAME} + secret-material-provider-ref: config-tree-primary + private-key-secret-ref: ${APP_FILESERVER_SFTP_PRIVATE_KEY_SECRET_REF} + known-hosts-secret-ref: ${APP_FILESERVER_SFTP_KNOWN_HOSTS_SECRET_REF} + allow-unknown-keys: false + session-cache-size: 4 + session-wait-timeout: 2s + connect-timeout: 5s + operation-timeout: 2m + require-extensions: + - posix-rename@openssh.com + control-plane: + mode: persistent-local + root-directory: ${APP_FILESERVER_SFTP_CONTROL_ROOT} + local-spool: + root-directory: ${APP_FILESERVER_SFTP_SPOOL_ROOT} + persistent: true + max-total-size: 20GB + at-rest-protection-profile-ref: spool-internal + + secret-material-providers: + config-tree-primary: + type: config-tree + root-directory: ${APP_FILESERVER_SECRET_CONFIG_ROOT} + auto-create: false + + format-profiles: + csv-machine: + type: csv + revision: csv-machine-v1 + dialect: rfc4180 + charset: UTF-8 + line-ending: CRLF + bom: false + formula-mode: preserve-machine-data + + protection-profiles: + internal: + revision: internal-v1 + payload-transform: none + spool-internal: + revision: spool-internal-v1 + payload-transform: none + require-encrypted-spool-volume: true + + retention-classes: + export-7d: + revision: export-7d-v1 + duration: 7d + mode: report-only +``` + +위 값은 topology 예시이며 제품별 실제 size/timeout 수치를 의미하지 않는다. + +### 23.2 Typed settings + +- immutable constructor-bound record; +- Bean Validation과 cross-field validator; +- duration/data-size typed value; +- explicit immutable policy revision과 canonical snapshot digest; +- provider별 sealed settings; +- blank/default path 금지; +- prod relative path 금지; +- secret material 대신 secret reference; +- unknown property fail; +- inactive provider는 bean/connection 생성 없음. + +### 23.3 Startup validation + +- destination/provider/profile/reference 존재; +- duplicate ID 없음; +- same ID/revision에 다른 canonical policy 금지; +- journal이 참조하는 N/N-1 frozen policy revision 가용; +- exact provider binding; +- required guarantee 충족; +- local-dev prod 금지; +- mounted root absolute/pre-provisioned; +- auto-create prod 금지; +- R2 control plane이 persistent이고 operation/reference direct lookup을 지원; +- SFTP spool/control root의 absolute/pre-provisioned/owner/mode/capacity; +- cluster-wide resolution을 요구하면 control volume의 모든 node 접근성과 coordination evidence; +- marker protocol과 consumer compatibility; +- replace mode와 provider replace capability; +- SFTP known-host와 bounded timeout/pool; +- secret provider root와 reference grammar, material owner/mode, rotation/session-drain policy; +- managed retention과 manifest support; +- staging/final same target namespace; +- required health/metrics registration. + +### 23.4 Environment registry + +`docs/registries/env-keys.yaml`, `application.yml`, typed settings, conditional beans를 end-to-end +검증한다. + +Template baseline에 필요한 key 예: + +```text +APP_FILESERVER_PRIMARY_ROOT +APP_FILESERVER_PRIMARY_MOUNT_ID +APP_FILESERVER_SFTP_HOST +APP_FILESERVER_SFTP_USERNAME +APP_FILESERVER_SFTP_PRIVATE_KEY_SECRET_REF +APP_FILESERVER_SFTP_KNOWN_HOSTS_SECRET_REF +APP_FILESERVER_SFTP_CONTROL_ROOT +APP_FILESERVER_SFTP_SPOOL_ROOT +APP_FILESERVER_SECRET_CONFIG_ROOT +``` + +Dynamic destination topology는 YAML/config tree가 소유하고 secret value는 secret source가 +제공한다. + +## 24. Health와 observability + +### 24.1 Health + +| Probe | 내용 | +| --- | --- | +| Liveness | JVM/process만; filesystem/SFTP 금지 | +| Startup | settings, destination graph, capability probe, manifest compatibility | +| Readiness | enabled + required destination만 | +| Component health | optional destination도 상태 노출 | + +Filesystem readiness: + +- root/mount sentinel; +- read-only/permission; +- mount identity; +- usable-space/inode watermark; +- cached bounded create/write/force/rename/delete probe; +- 전체 directory scan 금지. + +SFTP readiness: + +- bounded session acquire/connect/auth; +- host key; +- cached extension capability; +- remote root stat; +- pool saturation; +- write probe는 dedicated hidden probe directory에서 low-frequency opt-in. + +### 24.2 Metrics + +```text +fileserver.operation.duration +fileserver.operation.total{provider_type,operation,outcome,failure_code} +fileserver.bytes +fileserver.rows +fileserver.active +fileserver.queue.depth +fileserver.queue.rejected +fileserver.quota.rejected +fileserver.publish.indeterminate +fileserver.reconcile.total{outcome} +fileserver.integrity.failure +fileserver.staging.age +fileserver.staging.bytes +fileserver.cleanup.total{outcome} +fileserver.usable_space.ratio +fileserver.sftp.session.active +fileserver.sftp.session.wait +fileserver.sftp.reconnect +fileserver.formula.mitigated +``` + +Allowed tags: + +- provider type/ID from bounded registry; +- destination ID from bounded registry; +- operation kind; +- outcome/failure code; +- format profile; +- publication guarantee. + +Forbidden tags: + +- operation/file ID; +- file name/path; +- checksum; +- tenant/user; +- host when dynamically unbounded; +- row/cell content. + +### 24.3 Trace + +Span: + +```text +fileserver.publish +fileserver.stage +fileserver.provider.upload +fileserver.publish.commit +fileserver.reconcile +fileserver.cleanup +``` + +Attributes are bounded provider/destination/profile/guarantee/outcome only. Operation ID는 log/trace +correlation field로 사용할 수 있지만 metric tag로 쓰지 않는다. + +### 24.4 Log와 audit + +- physical path와 remote URI를 info log에 기록하지 않음; +- credential, known-host content, cell value 금지; +- publish/unknown/reconcile/delete는 structured event; +- overwrite/delete/retention은 audit 대상; +- diagnostic cause는 server log only; +- filename이 민감할 수 있으므로 logical name도 기본 mask. + +## 25. Threat model + +| 위협 | 통제 | +| --- | --- | +| `../`, absolute, drive/UNC | caller path 제거, destination ID + logical stem | +| nested/target symlink | secure relative operation, no-follow, trusted root | +| check/open TOCTOU | `SecureDirectoryStream` 또는 provider strict capability | +| hardlink attack | private directory UID/mode, untrusted writer 금지 | +| mount 누락 local fallback | pre-provision, auto-create off, mount sentinel/identity | +| partial final | staging + atomic rename/marker | +| old good file truncation | direct final write 금지 | +| arbitrary overwrite | create unique/expected version | +| same-name writer race | immutable name, provider primitive, external coordination | +| process crash | state/manifest/reconcile | +| publish ACK loss | INDETERMINATE, no blind retry | +| CSV formula | column formula policy | +| delimiter/control injection | schema/dialect encoder | +| heap/disk/inode exhaustion | streaming limits, quota, watermarks | +| PII leakage | path/content-free logs/metrics/receipt | +| SFTP MITM | pinned known-host/host CA | +| credential leakage | secret reference and log scrub | +| malicious remote server | size/time/request limits, strict extension parsing | +| forged manifest | trusted control dir 또는 signed manifest | +| reaper over-delete | owned manifest only, report-only, no recursive delete | +| NFS weak coherence | immutable versions, marker/receipt, no polling authority | +| advisory/lease lock loss | lock을 correctness boundary로 사용하지 않음 | +| stale resume corruption | digest/prefix/repeatability 검증 없으면 resume off | + +## 26. Test와 CI design + +### 26.1 Application contract tests + +- request/value/receipt invariant; +- receipt에 path/URI 없음; +- opaque reference direct lookup과 forged/unknown route 거부; +- operation ID fingerprint; +- effective policy snapshot digest와 frozen revision resume; +- 동일 completed operation에서 producer 0회; +- 하나의 accepted attempt에서 producer 최대 1회; +- global coordination capability가 없을 때 at-most-once claim 거부; +- producer source exception 보존; +- cooperative checkpoint/deadline과 blocking producer 한계 표면화; +- sink failure에서 staging abort; +- mismatch conflict; +- regeneration은 prior expected revision/attempt + new operation ID + exclusive supersession 필수; +- stale retry가 regeneration port를 우회하지 못함; +- Spring/Path/File/InputStream/vendor type leakage ArchUnit. + +### 26.2 CSV unit/property tests + +- RFC 4180 golden bytes; +- CRLF; +- comma/quote/CR/LF/multiline; +- UTF-8/emoji/multi-byte buffer boundary; +- malformed surrogate; +- null/empty; +- row width/type mismatch; +- blank/duplicate header; +- formula vectors와 whitespace/control prefix; +- cell/row/total byte limit; +- locale/timezone independence; +- randomized round-trip parser property. + +### 26.3 Local filesystem integration + +- staging/final same FileStore; +- restrictive create permission; +- reader가 partial final을 보지 않음; +- same operation concurrency; +- create conflict; +- expected-version replace; +- symlink parent/target swap; +- root/mount identity change; +- permission denied/read-only; +- ENOSPC/EDQUOT/inode exhaustion; +- write/flush/force/rename/dir-sync 단계별 fault; +- commit ACK 뒤 stat/reference-index/journal update failure와 `INDETERMINATE`; +- J-sealed/data/M-private/R-ref/J-final 각 경계 crash와 deterministic reconstruction; +- data-only atomic rename에서 public sidecar atomicity를 claim하지 않음; +- operation/reference journal atomic revision, corruption, direct recovery; +- journal과 final/manifest 모순의 truth-priority/quarantine; +- process kill 후 상태별 reconciliation; +- active writer와 reaper race; +- two reaper claim race; +- unknown file preservation; +- bounded heap with generated millions of rows. + +### 26.4 SFTP real integration + +실제 OpenSSH container를 production-readiness profile에서 사용한다. + +- known-host success/mismatch/rotation; +- key auth failure; +- connect/auth/read/write/overall timeout; +- bounded pool wait/reject; +- large spool/upload; +- disconnect during upload; +- disconnect before/after rename ACK; +- persistent spool/control volume 유실 및 재시작 receipt 복원; +- server restart; +- extension present/absent; +- `posix-rename`, `fsync`, `statvfs`, `limits`; +- remote permission; +- private-key/known-host rotation에서 old session drain과 새 version 사용; +- secret reference 미해결, path escape, material log/heap fixture 부재; +- part/final/marker reconciliation; +- resume prefix mismatch; +- graceful shutdown. + +Mock-only test로 SFTP guarantee를 증명하지 않는다. + +### 26.5 NFS/multi-client + +전용 Linux runner 또는 nightly profile: + +- 실제 NFS server와 두 mount client; +- same-fsid rename; +- close/reopen visibility; +- directory cache delay; +- server restart; +- temporary disconnect/stale handle; +- lease expiry; +- async export profile 거부; +- mount missing/local fallback; +- two-node immutable publication; +- marker-aware consumer. + +NFS service가 없으면 production-readiness job은 skip하지 않고 실패한다. + +### 26.6 Maintenance/security/observability + +- active stage 보존; +- stale/unknown/newer manifest 처리; +- report-only default; +- retention/legal hold/version; +- repeated cleanup idempotency; +- metric cardinality; +- path/content/credential log absence; +- trace propagation; +- readiness cache; +- liveness independence; +- provider capability mismatch startup failure; +- content producer가 checkpoint를 호출하지 않는 경우 hard-timeout을 주장하지 않음; +- control plane persistence/cluster access와 secret provider binding validation. + +### 26.7 Compatibility + +- manifest N/N-1 read; +- format/protection/retention/binding frozen policy N/N-1 resume; +- same profile ID/revision의 canonical digest drift 기동 실패; +- newer schema quarantine; +- rolling deployment writer/reader matrix; +- OpenSSH supported-version matrix; +- NFS server/client supported matrix; +- Linux/macOS/Windows local grammar; +- legacy `exportCsv` migration wrapper; +- public contract snapshot. + +### 26.8 CI tasks + +```text +:adapter:outbound:fileserver:test +:adapter:outbound:fileserver:integrationTest +:adapter:outbound:fileserver:sftpIntegrationTest +:adapter:outbound:fileserver:filesystemFailureTest +:adapter:outbound:fileserver:securityTest +:adapter:outbound:fileserver:contractTest +fileserverProductionReadiness +``` + +PR: + +- application/CSV/local contract; +- architecture/config gating; +- deterministic OpenSSH baseline. + +Nightly: + +- NFS multi-client; +- failure injection; +- supported provider matrix; +- process kill/recovery; +- longer concurrency/heap soak. + +## 27. Gradle, dependency, bootstrap design + +### 27.1 Dependency ownership + +현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK, external dependency +없음, NFS/SFTP stand-in만을 허용한다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수 +없다. 구현 Phase 0에서 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다. + +- local `CLAUDE.md`의 책임을 local-only demo에서 provider-based publication으로 변경; +- external `NONE` 규칙을 exact allowlist로 변경; +- broad `spring-boot-starter` 허용 문구를 실제 narrow autoconfigure 정책으로 수정; +- README의 registry SSOT와 runtime composition 설명 수정; +- architecture/Gradle test가 새 allowlist를 강제. + +이 rule migration 전 SFTP dependency 추가나 runtime activation은 HARD-STOP이다. + +`adapter-outbound-fileserver`: + +- JDK NIO local/mounted provider; +- Spring autoconfigure; +- SLF4J API; +- Micrometer/Observation instrumentation if direct; +- `spring-integration-sftp` implementation dependency; +- Apache MINA types transitive/implementation only; +- provider test tools in test configurations. + +`application-core`: + +- project dependencies와 Java standard types only; +- SFTP/Spring/Micrometer 없음. + +SFTP dependency는 `api`로 노출하지 않고 Boot BOM compatible version을 사용한다. 별도 broad +starter를 추가하지 않는다. + +### 27.2 Bootstrap composition + +안전한 explicit binding/gating과 config test가 먼저 구현된 후: + +1. `modules.json`의 `app-bootstrap.allowed_dependencies`에 + `adapter-outbound-fileserver` 추가; +2. `app-bootstrap/build.gradle`에 implementation dependency 추가; +3. exact module count는 19 유지; +4. no destination이면 zero bean/connection/scheduler; +5. optional adapter gating test에 fileserver 추가; +6. disabled-adapter architecture scan에 fileserver 추가; +7. env/settings/readiness contract 추가. + +Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된다. + +### 27.3 SDK split trigger + +다음 중 하나가 실제로 발생하면 `fileserver-sftp` leaf split ADR을 작성한다. + +- SFTP SDK security patch cadence가 독립적; +- local-only runtime에서 SFTP transitive dependency 제거 필요; +- provider별 deployment artifact 분리; +- 팀/릴리스 ownership 분리; +- module test/runtime 시간이 독립 관리되어야 함. + +## 28. Migration + +### Phase 0 — Truthful topology와 contract freeze + +- 현재 Fileserver를 R1 local CSV demo로 명시; +- Fileserver `CLAUDE.md`와 README의 responsibility/dependency/registry SSOT drift 수정; +- current bootstrap 미합성 상태 명시; +- v2 contract와 error registry 승인; +- journal/reference/control-plane schema 승인; +- accepted-attempt와 global coordination guarantee 분리; +- effective policy freeze/revision/digest와 N/N-1 resume 정책 승인; +- explicit regeneration/supersession 계약 승인; +- protocol별 artifact publication/crash ordering 승인; +- secret material SPI와 lifecycle 승인; +- activation/binding/settings schema 승인; +- 기존 API deprecation 계획. + +Acceptance: + +- 문서와 startup diagnostics가 NFS/SFTP 구현이 있다고 주장하지 않는다. + +### Phase 1 — Streaming application contract와 CSV + +- `FilePublicationPort`; +- operation ID/fingerprint; +- effective policy snapshot; +- row producer/sink; +- typed cell/schema; +- CSV profiles/formula/limits; +- opaque receipt; +- legacy adapter wrapper. + +Acceptance: + +- 전체 rows/CSV/byte[] materialization 없이 bounded heap test 통과. + +### Phase 2 — Secure local/mounted publication + +- staging; +- digest/manifest; +- sealed journal과 protocol별 artifact ordering; +- file sync; +- atomic publish; +- path/permission/mount safety; +- error/outcome state; +- local reconciliation. + +Acceptance: + +- reader partial final 0건, 단계별 crash recovery, symlink race test 통과. + +### Phase 3 — Resource/maintenance/observability + +- concurrency/byte quota; +- timeout/cancel/shutdown; +- staging reaper/report; +- managed retention; +- health/metrics/traces/audit. + +Acceptance: + +- capacity failure가 bounded하고 unknown file을 삭제하지 않는다. + +### Phase 4 — SFTP provider + +- Spring Integration/Apache MINA; +- host key/secrets; +- bounded pool/timeouts; +- local spool; +- extension negotiation; +- remote unknown-outcome reconciliation; +- OpenSSH contract tests. + +Acceptance: + +- required extension 없음, ACK loss, server restart 경로가 silent downgrade 없이 검증된다. + +### Phase 5 — NFS/HA evidence와 bootstrap + +- multi-client NFS profile; +- operator attestation; +- app-bootstrap composition; +- env/readiness/architecture gates; +- sample/reference export workflow. + +Acceptance: + +- 선택한 deployment profile의 effective guarantee만 R2로 표시된다. + +### Phase 6 — Optional read/delete와 module split review + +- opaque content transfer; +- expected-version managed delete; +- provider split 조건 재평가; +- rolling compatibility matrix. + +## 29. 완료 기준 + +Fileserver R2 완료를 주장하려면: + +- application contract에 path/provider/SDK가 없음; +- bounded-memory streaming; +- stable operation ID와 fingerprint; +- effective policy revision/digest freeze와 rolling resume; +- durable operation/reference control plane과 restart receipt 복원; +- accepted-attempt와 global producer execution guarantee를 분리; +- direct final write 없음; +- no silent atomicity/durability downgrade; +- receipt에 achieved guarantee와 opaque reference; +- local/mounted/SFTP provider가 각자 capability card 제공; +- required guarantee startup validation; +- symlink/mount/permission/host-key security; +- secret reference resolution/rotation/session-drain lifecycle; +- create/replace concurrency semantics; +- unknown outcome reconciliation; +- explicit regeneration/supersession revision guard; +- commit 후 confirm/control-record 실패의 `INDETERMINATE` 처리; +- protocol별 data/manifest/reference/journal crash ordering; +- quota/backpressure/timeouts/shutdown; +- cooperative content deadline과 hard provider timeout의 분리; +- manifest/reaper/retention 안전성; +- CSV schema/formula/encoding/limit; +- classified error; +- bounded observability; +- real provider/failure/compatibility tests; +- app-bootstrap opt-in composition; +- runbook과 capacity inputs; +- core dependency purity와 exact-19 architecture gate 통과. + +다음 문구는 금지한다. + +- “NFS/SFTP stand-in이므로 production-ready” +- “rename이 atomic이므로 crash durable” +- “fsync를 호출했으므로 모든 remote storage에서 durable” +- “FileLock으로 distributed correctness 보장” +- “CSV quoting으로 formula injection 해결” +- “retry하면 같은 파일이 정확히 한 번 생성” +- “path normalize로 symlink 공격 해결” +- “usable space가 남았으므로 quota 확보” + +## 30. 운영 runbook 요구 + +- mounted root/mount identity mismatch; +- disk/inode/tenant quota; +- staging backlog/orphan; +- publish indeterminate; +- checksum/manifest mismatch; +- SFTP host-key rotation; +- SFTP credential rotation; +- pool saturation/session leak; +- remote extension/version drift; +- NFS server restart/stale handle; +- async/sync export configuration; +- retention legal hold; +- manifest schema rolling upgrade; +- cleanup report-only에서 delete mode 전환; +- backup/restore 후 operation reconciliation; +- frozen policy/key revision unavailable; +- regeneration approval과 supersession conflict; +- committed data와 private control record 불일치. + +## 31. Primary references + +- [Java 21 `Files.move` and `ATOMIC_MOVE`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/Files.html#move(java.nio.file.Path,java.nio.file.Path,java.nio.file.CopyOption...)) +- [Java 21 `FileChannel.force`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/channels/FileChannel.html#force(boolean)) +- [Java 21 `SecureDirectoryStream`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/SecureDirectoryStream.html) +- [Java 21 `FileLock` platform dependencies](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/channels/FileLock.html) +- [Java 21 `FileStore.getUsableSpace`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/FileStore.html#getUsableSpace()) +- [Linux `fsync(2)`](https://man7.org/linux/man-pages/man2/fsync.2.html) +- [Linux `renameat2(2)`](https://man7.org/linux/man-pages/man2/renameat2.2.html) +- [Linux `openat2(2)`](https://man7.org/linux/man-pages/man2/openat2.2.html) +- [NFSv4.1 RFC 8881 RENAME](https://datatracker.ietf.org/doc/html/rfc8881#section-18.26.3) +- [NFSv4.1 RFC 8881 WRITE](https://datatracker.ietf.org/doc/html/rfc8881#section-18.32.3) +- [NFSv4.1 RFC 8881 COMMIT](https://datatracker.ietf.org/doc/html/rfc8881#section-18.3.3) +- [Spring Integration SFTP support](https://docs.spring.io/spring-integration/reference/sftp.html) +- [Spring Integration SFTP outbound temporary-name protocol](https://docs.spring.io/spring-integration/reference/sftp/outbound.html) +- [Spring Integration SFTP session caching](https://docs.spring.io/spring-integration/reference/sftp/session-caching.html) +- [Spring Integration `SftpSession` API](https://docs.spring.io/spring-integration/docs/current/api/org/springframework/integration/sftp/session/SftpSession.html) +- [OpenSSH SFTP extensions](https://raw.githubusercontent.com/openssh/openssh-portable/master/PROTOCOL) +- [SFTP v3 draft](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02) +- [RFC 4180 CSV](https://www.rfc-editor.org/info/rfc4180/) +- [OWASP CSV Injection](https://owasp.org/www-community/attacks/CSV_Injection) diff --git a/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md b/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md new file mode 100644 index 0000000..6a7364f --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md @@ -0,0 +1,1346 @@ +# Production Capability Platform Design + +- Date: 2026-07-26 +- Status: Proposed for implementation approval +- Scope: architecture and staged implementation design only +- Baseline: Java 21, Spring Boot 4.0.0, Gradle multi-module Clean Architecture template + +## 1. Executive decision + +This repository should evolve from a collection of integration seams into an **opt-in production +capability platform**. + +The target is not to enable Redis, Kafka, MongoDB, sessions, CDC, and every transport in every +application. The target is: + +1. a developer selects a capability and provider by typed configuration; +2. the composition root validates the selected topology and guarantees at startup; +3. application code depends only on semantic, framework-free ports; +4. the selected adapter supplies a real client, bounded defaults, health, metrics, failure + semantics, and reusable contract tests; +5. unused capabilities create no connection, background worker, schema, or implicit runtime + behavior; +6. advanced strategies remain available without pretending that one strategy is correct for every + domain. + +The default template remains light. Production capability packs are **available by default but +inactive by default**. A capability is not called production-ready merely because a class or client +seam exists. + +### Primary decisions + +- Keep `domain-core`, `application-core`, and `shared-contract` free of Spring, Redis, Kafka, + persistence, transport, and observability SDK types. +- Put use-case-owned semantic ports and application policies in `application-core`; put + skeleton-wide transport/operational contracts in `shared-contract`; put provider selection and + Spring composition in `app-bootstrap`. +- Initially preserve the registered 19-leaf topology. Expand the existing technology leaves in + cohesive packages and split a leaf only when inbound/outbound direction or independent lifecycle + requires it. +- Treat `adapter:outbound:cache-redis` as the first Redis technology capability provider, but never + reuse cache's fail-open behavior for sessions, idempotency, locks, or strict rate limits. +- Add a future `adapter:inbound:messaging-kafka` leaf before implementing Kafka consumers. A + consumer is a driving adapter and does not belong in the existing outbound producer leaf. +- Keep an outbox append operation in the same source-of-truth datastore transaction as the business + write. Make the **dispatch mechanism** selectable: polling or CDC. +- Define end-to-end messaging as at-least-once delivery plus idempotent consumers/inbox. Do not + advertise generic exactly-once delivery across a database and broker. +- Separate evictable cache data from correctness-sensitive Redis data at the Redis deployment or + cluster level, not only by key prefix or database number. +- Provide capability-level safe operations and versioned Lua scripts. Do not expose a general + `RedisTemplate`, Kafka producer, HTTP client, or cloud SDK to use cases. + +## 2. Scope and non-goals + +This design covers: + +- Redis cache, rate limiting, sessions, idempotency, locks, and reusable atomic operations; +- polling and CDC outbox, Kafka producer/consumer, inbox, delivery semantics, and query/read models; +- file server, object storage, HTTP client, notification, JPA, MongoDB, web, GraphQL, gRPC, and + WebSocket production baselines; +- provider selection, typed settings, health, observability, Gradle ownership, and test strategy; +- a phased path from the current skeleton to an operational baseline. + +This design intentionally does not: + +- select one infrastructure topology for every future product; +- invent domain-specific throughput or latency numbers; +- claim benchmark improvements without a real workload and environment; +- make every optional dependency active in the default application; +- promise cross-store atomicity, generic exactly-once processing, or strong consistency from a + Redis lock; +- place example business concepts in production modules; +- create a universal repository, universal query DSL, or a raw infrastructure facade in + `application-core`. + +## 3. Evidence-based current state + +The repository already has stronger boundaries than a typical starter, but many adapters stop at an +extension seam. + +| Capability | Current evidence | Current operational gap | +| --- | --- | --- | +| Redis cache | `CacheStore` exposes only `get/put(String)` and `RedisClient` is project-supplied | No Redis SDK, TTL, delete, CAS, bulk operations, serialization policy, real health, or integration test | +| Rate limit | `RateLimitAlgorithm` has only `FIXED_WINDOW`; `FixedWindowRateLimiter` is in-process | Not sliding-window as previously assumed; not multi-node; key map has no removal policy | +| Session | `SecurityConfig` fixes JWT, CSRF disabled, and `STATELESS` | No Redis session repository, stateful security profile, rotation, shared logout, or multi-pod contract | +| Idempotency | Framework-free executor/port plus JPA implementation | Port documentation is DB-specific; no owner token; execution lease and replay TTL are conflated; Redis provider absent | +| Lock | Framework-free lock port plus local/JDBC `LockRegistry` providers | Multi-node means JDBC only; no owner token, renewal, lease-lost signal, fencing, or Redis provider | +| Outbox | JPA/PostgreSQL polling relay with claim, retry, FIFO, and metrics | PostgreSQL `SKIP LOCKED` and mutable status row are coupled to polling; no CDC mode or immutable CDC envelope | +| Kafka | `KafkaSender` is a project-supplied seam and the module has no Kafka SDK | No broker acknowledgement, producer security/tuning, consumers, inbox, retries, DLT, schema contract, or rebalance handling | +| HTTP client | Connect/read timeout, a `globalCallTimeout` label, retry, circuit breaker, buffered size bound, and diagnostics exist | The global value neither bounds nor cancels an active call; no explicit bulkhead, per-client pool controls, SSRF policy, TLS/mTLS profile, redirect policy, or OTel-owned propagation | +| Object storage | Actual filesystem and synchronous S3/MinIO adapters exist | Entire object is `byte[]`; unsafe local-oriented defaults; no multipart, presigned operation, checksum contract, encryption, lifecycle, or orphan cleanup | +| File server | CSV is built and written with JDK filesystem APIs | Whole export is buffered; overwrite is non-atomic; no fsync/rename protocol, quota, retention, symlink defense, or shared-filesystem semantics | +| Notification | Route/fan-out/fail-open framework plus Google/Slack client seams | No real provider SDK, durable delivery, template/versioning, preference, dedupe, receipt, fallback, or provider rate control | +| MongoDB | Opt-in Spring Data Mongo configuration | No production document/port contract kit, concern policy, index/migration contract, replica-set transaction test, or change-stream checkpoint | +| GraphQL | Minimal schema/controller and error resolver | No depth/complexity policy, persisted queries, DataLoader baseline, field authorization, production schema checks, or subscription policy | +| gRPC | Netty server lifecycle, health, reflection, and error mapping | No feature proto build convention, TLS/mTLS, auth, deadline enforcement, retry contract, message limits, or stream backpressure baseline | +| WebSocket | In-process domain-event to STOMP simple-broker bridge | Not cluster-safe or durable; no broker relay profile, destination auth, bounded queues, reconnect/resume, or explicit drop policy | +| Observability | Structured logging, Micrometer, OTel bridge, actuator, and selected metrics exist | Coverage is uneven; several registry entries are not wired; manual `traceparent` propagation competes with real instrumentation | +| Bootstrap composition | The default `app-bootstrap` graph omits file server, object storage, MongoDB, GraphQL, gRPC, and WebSocket leaves | Their source presence is not a runtime guarantee; blindly adding all dependencies would also activate unsafe defaults | +| Settings contract | YAML, environment-key registry, typed settings, and conditional beans are not consistently end-to-end aligned | Notification provider keys diverge, and JWT exposes JWKS/clock-skew settings while the implementation uses issuer discovery and a fixed skew | + +Important correctness findings: + +- `IdempotencyStorePort.complete(scope)` and `discard(scope)` cannot distinguish an expired original + owner from a new owner. A stale caller can overwrite or delete a reclaimed record. +- The multi-instance startup validator checks bean names, not capability types or guarantees. Its + test accepts plain `Object` beans, so it can report safety without a working distributed + implementation. +- The current outbox row is mutated through `PENDING/IN_FLIGHT/PUBLISHED/...`. Debezium's standard + Outbox Event Router expects the outbox event source to behave as an insert-only queue, so CDC + cannot be attached to the current mutable design without a schema/behavior split. +- The current authenticated rate-limit key lacks a route or policy dimension, while the + unauthenticated key includes the route. This makes policy isolation inconsistent. +- Blanket `catch (Exception)` cache fail-open behavior can hide codec or programming defects as + ordinary cache misses. +- Merely composing every existing leaf is unsafe: GraphQL and WebSocket lack a uniform module + enable gate, gRPC defaults include plaintext/reflection behavior, and filesystem object storage + can activate through a missing-property default. +- The HTTP decorator order currently lets the circuit breaker observe a complete retry bundle, + while its documentation says each attempt is counted. The declared global call timeout also + does not actively cancel an already-running attempt. + +## 4. Alternatives considered + +### A. One large infrastructure starter in `app-bootstrap` + +This is rejected. It would make classpath presence activate too much auto-configuration, blur +provider ownership, and turn the composition root into an infrastructure implementation module. + +### B. A new Gradle leaf for every capability-provider pair + +Examples would be `cache-redis`, `lock-redis`, `session-redis`, `idempotency-redis`, and +`rate-limit-redis`. This has the cleanest physical isolation but creates module and connection +configuration duplication immediately. It also changes the exact 19-leaf registry before the +semantic contracts are stable. + +This becomes appropriate only when a provider has an independent release cadence, security +boundary, deployment lifecycle, or dependency graph. + +### C. Semantic ports plus existing technology leaves + +This is the selected first-stage design. + +- `application-core` owns semantic ports and framework-free orchestration. +- existing adapter leaves own actual SDKs and provider-specific behavior; +- `app-bootstrap` selects exactly one provider per required capability; +- provider packages use separate failure policies even when they share a client library; +- module splits are made only for direction or lifecycle reasons. + +The known compromise is the `cache-redis` module name. Its short-term responsibility becomes Redis +technology capabilities, while a rename to `adapter:outbound:redis` is deferred to a separately +approved module-registry migration. + +### D. Extract an external platform BOM/starter repository now + +This is deferred. Extraction before the contracts and test kits are proven would freeze immature +APIs and make local architecture verification harder. A later extraction may publish provider +artifacts and a BOM after at least two real consumers validate the contracts. + +## 5. Target architecture + +```mermaid +flowchart LR + WEB[Web / GraphQL / gRPC / WebSocket] --> APP[application-core use cases] + KIN[Future inbound Kafka adapter] --> APP + APP --> PORTS[Semantic outbound ports] + PORTS --> JPA[JPA/PostgreSQL providers] + PORTS --> MONGO[Mongo providers] + PORTS --> REDIS[Redis capability providers] + PORTS --> MSG[Messaging producer providers] + PORTS --> IO[HTTP / notification / storage / files] + BOOT[app-bootstrap composition root] -. selects and validates .-> WEB + BOOT -. selects and validates .-> JPA + BOOT -. selects and validates .-> MONGO + BOOT -. selects and validates .-> REDIS + BOOT -. selects and validates .-> MSG + OBS[Metrics / traces / logs / health] -. decorates runtime edges .-> BOOT +``` + +### Layer ownership + +| Concern | Owner | Must not leak | +| --- | --- | --- | +| Domain invariant, domain event | `domain-core` | Spring, SDK, transport, persistence | +| Application-invoked cache/idempotency/lock/inbox semantic contract | `application-core` | Redis, SQL, Kafka, Servlet, Micrometer | +| Feature-specific query shape | feature application package via `*QueryPort` | JPA entity, web DTO, generic repository | +| Transport-edge rate limit and operational descriptor contracts shared across leaves | `shared-contract` | provider SDK, Servlet, Redis, and business/domain concepts | +| Redis connection, codec, scripts, cache/rate/idempotency/lock provider | `adapter:outbound:cache-redis` initially | use-case and business policy | +| Session repository implementation | Redis provider package; security behavior composed by web/bootstrap | Spring Session types in application/domain | +| JPA transaction, same-store outbox/inbox/idempotency, DB lock | `adapter:outbound:persistence-jpa` | use-case policy | +| Kafka producer | `adapter:outbound:messaging` | consumer handler and use case | +| Kafka consumer | future `adapter:inbound:messaging-kafka` | producer implementation and persistence entity | +| HTTP/security/response mapping | relevant inbound adapter | repository/client SDK | +| Provider selection, exact-one validation, health composition | `app-bootstrap` | business policy | + +### New or revised core contracts + +The implementation plan should introduce or revise these contracts: + +- `CachePort`, `CacheRegion`, `CacheKey`, `CacheLookup`, `CacheWritePolicy`, + `CacheAsideExecutor`; +- transport-edge `EdgeRateLimitContract`, `RateLimitRequest`, and `RateLimitDecision` in + `shared-contract`; a use-case-owned `BusinessQuotaPort` belongs in `application-core` only when a + domain/application policy actually invokes it; +- owner-token-based `IdempotencyStorePort` claim/renew/complete/release; +- existing `DistributedLockPort` documented as an efficiency mutex; +- separate `FencedLockPort`, and later `LeaderElectionPort`, `SemaphorePort`, or `WorkClaimPort` + only when those semantics are needed; +- `InboxStorePort` and `MessageConsumptionExecutor`; +- richer outbox/integration-message envelope; +- `ReadConsistency` and versioned cursor values used by feature-specific query ports; +- streaming and precondition-oriented object/file contracts. + +These are deliberately **not** application ports: + +- a raw Redis command API; +- `RedisTemplate`, Lettuce/Jedis connections, or Spring Session repositories; +- a generic Kafka producer/consumer; +- a generic `RestClient`; +- a general cloud SDK facade; +- a generic repository or arbitrary query language. + +Session is a transport/security concern. A `SessionControlPort` is added only if an application use +case must revoke a user's sessions, enforce a business-driven concurrent-session rule, or audit +session control. + +## 6. Capability readiness model + +Each capability has a visible readiness level: + +| Level | Meaning | Required evidence | +| --- | --- | --- | +| R0 Contract | Type, seam, or placeholder only | unit tests and architecture boundary | +| R1 Local | Works in one local process or local service | focused integration test and documented limitations | +| R2 Production baseline | Real provider, safe configuration, failure semantics, health, metrics, security, graceful lifecycle | provider contract, real-service integration, concurrency/failure tests, runbook | +| R3 Scale/HA | Cluster/failover/rolling-upgrade behavior is proven | topology tests, compatibility matrix, recovery and capacity runbooks | + +The current repository contains a mixture of R0, R1, and partial R2 components. Documentation and +startup diagnostics must not label an R0 seam as an R2 provider. + +Every capability receives a "capability card" containing: + +- owner module and semantic port; +- provider IDs and readiness level; +- guarantee and explicit non-guarantees; +- default failure mode and allowed overrides; +- required topology and persistence/eviction policy; +- configuration and secrets; +- liveness/readiness impact; +- bounded-cardinality metrics and trace spans; +- fit/non-fit guidance, cost model, and resource bounds; +- common unsafe recipes, the race/failure they create, and the safe replacement; +- focused, integration, and failure-test commands; +- rolling-upgrade and recovery notes; +- runbook links. + +This is how the template teaches operational depth without forcing every project to use every +feature. R2 examples are executable contract examples, not copy-only snippets: a provider must +prove its documented concurrency and failure semantics before the card can claim the guarantee. + +## 7. Provider selection and configuration + +Provider selection becomes explicit per capability. `multi-instance-enabled` remains descriptive +runtime context, not an implicit provider selector. + +`provider: disabled | ` is the activation SSOT for a selectable capability. A binding +map uses `disabled | ` per binding, and a mode capability such as outbox uses +`dispatch-mode: disabled | polling | cdc`. A leaf-level `enabled` flag exists only when there is no +provider or mode axis. If a legacy flag is temporarily retained, any disagreement with the SSOT is +a startup error. + +`matchIfMissing=true`, classpath presence, or a local-provider default must never activate a +production capability. Local, in-memory, plaintext, reflection, auto-create, and filesystem +profiles are rejected in a production profile unless a deployment explicitly opts into a +documented exception. + +Illustrative target configuration: + +```yaml +ca-skeleton: + capabilities: + cache: + bindings: + worklog-summary: redis + rate-limit: + provider: redis + fallback: local-emergency + idempotency: + provider: jdbc + guarantee: same-store-transactional + lock: + provider: jdbc + guarantee: efficiency + outbox: + dispatch-mode: polling + messaging: + producer: kafka + consumer: disabled + security: + auth-mode: jwt + + providers: + redis: + connections: + cache: + endpoint: ${REDIS_CACHE_ENDPOINT} + coordination: + endpoint: ${REDIS_COORDINATION_ENDPOINT} + session: + endpoint: ${REDIS_SESSION_ENDPOINT} +``` + +Credentials, keys, and certificates are secret references or environment-provided values, not +literal repository defaults. + +### Capability descriptors + +Each active provider contributes a typed descriptor with at least: + +- `capabilityId`; +- `providerId`; +- readiness level; +- guarantee class; +- failure mode; +- multi-instance support; +- required backing role; +- readiness impact; +- implementation version. + +`app-bootstrap` validates descriptors and selected settings. It does not accept arbitrary beans +with a magic name. + +Examples of fail-fast topology rules: + +- `redis-session` requires the session Redis role, CSRF/cookie settings, and a session repository; +- `idempotency=redis` cannot claim same-store transactional atomicity for a JDBC business write; +- `outbox=cdc` and the polling scheduler cannot both be active; +- `multi-instance=true` rejects an in-process-only required lock or rate-limit provider unless an + explicit degraded mode is selected; +- a required Kafka producer must support broker acknowledgement and bounded delivery timeout; +- a correctness capability cannot use the evictable cache Redis role; +- no enabled provider may have an unregistered health, metrics, or configuration card. + +Configuration contract tests traverse the complete path: + +```text +environment-key registry -> application YAML -> typed settings -> validation -> conditional bean +``` + +They fail on unknown/dead keys, missing registered keys, multiple active providers, inactive +provider settings that unexpectedly create beans, or a selected provider without its SDK and +health indicator. + +### Failure modes + +Failure policy is capability-specific, not globally "fail open" or "fail closed". + +| Capability | Default failure policy | +| --- | --- | +| Optional cache | Fail open to source load; surface degraded result and metric | +| Cache codec/programming error | Fail closed for the operation; evict/quarantine corrupt entry; do not hide as miss | +| Security session store | Fail closed; authentication state must not be invented | +| Keyed mutation idempotency | Fail closed | +| Strict rate limit for abuse/cost boundary | Fail closed or a deliberately bounded local emergency limiter | +| Availability-oriented rate limit | Explicit local emergency fallback, never unlimited silent bypass | +| Efficiency lock | Fail or continue only according to declared use-case policy | +| Fenced correctness lock | Abort protected work on acquisition or lease-loss failure | +| Outbox append | Roll back the business transaction | +| Outbox dispatcher outage | Keep writes accumulating; alert on lag/backlog | +| Best-effort notification/message | Explicit fail open | +| Durable notification/message | Outbox/inbox, retry, and terminal failure path | + +## 8. Redis capability design + +The Redis summary in this section is expanded and governed by +[Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md). That +document fixes the application contracts, role/deployment isolation, key and codec schemas, +versioned Function/Lua catalog, cache strategies, rate-limit algorithms, lease/fencing and +idempotency state machines, session profile, client backpressure, security, observability, and +topology/failure CI in implementation-ready detail. + +### 8.1 Runtime role isolation + +At minimum, Redis is modeled as three roles: + +| Role | Data | Eviction/durability expectation | Typical failure semantics | +| --- | --- | --- | --- | +| `cache` | recomputable values, negative entries, soft locks | bounded memory, an `allkeys-*` policy selected by operations | usually fail open | +| `coordination` | idempotency, locks, strict quotas, fencing counters | `noeviction`, HA, persistence/capacity alarms | usually fail closed | +| `session` | authenticated sessions and indexes | `noeviction`, HA, serializer compatibility, persistence | fail closed | + +A Redis database number or key prefix does not isolate `maxmemory`, eviction, failover, or noisy +neighbors. Production settings require separate managed databases, clusters, or instances where +these guarantees differ. + +Each role has typed endpoint/topology/TLS/ACL/timeout/pool/topology-refresh settings and its own +health component. Application code never issues `CONFIG SET`; deployment configuration owns +`maxmemory`, eviction, persistence, and replica policy. + +### 8.2 Safe operation catalog + +The Redis leaf provides reusable, capability-oriented operations: + +| Category | Baseline operations | +| --- | --- | +| Cache | get, multi-get, put with bounded TTL, put-if-absent, evict, multi-evict, touch, namespace-generation bump | +| Atomic primitives | set-if-absent-with-TTL, compare-and-delete, compare-and-expire, increment-with-initial-TTL | +| Rate limit | fixed window, sliding counter, token bucket; sliding log and GCRA opt-in | +| Idempotency | atomic claim, renew, complete, release with owner token | +| Lock | acquire, owner-safe renew/release, optional atomic fencing counter | +| Streaming/invalidation | bounded Redis Streams or Pub/Sub helpers only inside messaging/cache adapters | + +These are adapter utilities, not use-case APIs. Raw list/set/hash commands remain available to a new +adapter implementation through the client library, but are not promoted as a stable application +contract. + +Every packaged script has a `ScriptDescriptor`: + +- stable name and semantic version; +- source checksum; +- key count and cluster-slot rule; +- argument/result schema; +- complexity and maximum collection size; +- timeout/failure behavior; +- metrics name; +- compatible Redis versions. + +Every public helper also has an operation contract that states atomicity scope, command/script +complexity, worst-case state growth, cluster-slot constraints, clock source, retry safety, and +failure result. Its documentation pairs the common unsafe multi-command recipe with the packaged +atomic replacement, and its provider contract contains a concurrent race test. This makes the +reason for Lua or another primitive visible to a developer instead of presenting a magic helper. + +### 8.3 Why Redis single-threading does not remove races + +An individual Redis command is serialized, but a client sequence such as: + +```text +GET -> decide -> INCR -> EXPIRE +``` + +is not one command. Other clients can interleave between its steps. The `INCR` may succeed while +`EXPIRE` is skipped after a client failure, or two clients can both make a decision from stale +state. + +Lua scripts solve the read/decide/write atomicity problem by running as one atomic server-side +operation. They introduce another risk: Redis blocks other work while a script runs. Therefore: + +- scripts are O(1) or strictly bounded; +- no unbounded loop, `KEYS`, large collection scan, network, filesystem, or dynamic code; +- keys are declared through `KEYS[]`; +- multi-key scripts use a common, narrow hash tag such as a resource hash, not a whole-tenant hot + slot; +- time arithmetic uses integer milliseconds and a documented clock source; +- scripts are classpath resources, not concatenated strings; +- the client uses cached execution (`EVALSHA`) with safe reload on `NOSCRIPT`; +- Redis Functions are an opt-in deployment mode only when function installation/version ownership + is available; +- slow-script, command-timeout, pool, and server-latency signals are monitored. + +### 8.4 Cache contract and strategies + +The current `String get/put` API is replaced by an application-owned cache contract that separates: + +- `HIT`, `MISS`, `NEGATIVE_HIT`, and `DEGRADED`; +- region and key from backend provider; +- payload schema/version from Redis serialization; +- positive TTL, negative TTL, soft TTL, and hard TTL; +- backend failure from codec/programming failure. + +The Redis adapter stores an opaque, versioned payload. A framework-free `CacheCodec` and +`CacheAsideExecutor` keep domain/application values type-safe without allowing the Redis adapter +to serialize arbitrary domain objects by reflection. JDK native serialization is forbidden. + +Baseline strategies: + +- cache-aside; +- after-commit invalidation, with eviction preferred over blind cache update; +- bounded positive/negative TTL; +- TTL jitter; +- process-local single-flight; +- versioned key namespace; +- payload size limit; +- batched `SCAN` plus `UNLINK` for operator cleanup, never regular `KEYS`; +- low-cardinality hit/miss/error/load metrics. + +Opt-in strategies: + +- stale-while-revalidate with soft/hard TTL; +- refresh-ahead; +- probabilistic early refresh; +- L1 local plus L2 Redis; +- distributed stampede suppression with double-check and bounded soft lock; +- client-side tracking or Pub/Sub invalidation; +- compression above a configured threshold; +- generation-based mass invalidation. + +Write-through cannot imply atomic DB+Redis commit. Write-behind is not an in-memory executor behind +the cache API; it requires a durable outbox/stream consumer and its own retry/DLT semantics. +Pub/Sub invalidation is best effort, so TTL and schema version remain the recovery boundary. + +### 8.5 Rate-limit strategy registry + +Transport adapters resolve principal, tenant, API key, IP, route, and policy, then invoke the +framework-neutral `EdgeRateLimitContract` from `shared-contract`. The Redis provider implements +that contract and `app-bootstrap` wires the edge; `application-core` is not involved unless a +separate business quota is part of a use case. The contract receives only an opaque hashed subject +and policy: + +```text +RateLimitRequest(policyId, subjectHash, cost) + -> RateLimitDecision(allowed, remaining, retryAfter, resetAt) +``` + +The stable key dimensions are: + +```text +environment + policyId + tenant? + subjectHash +``` + +Raw PII, token, request body, and full URL are forbidden in Redis keys, logs, traces, and metric +tags. + +| Algorithm | Operational characteristic | Availability | +| --- | --- | --- | +| Fixed window | O(1), simple, permits boundary burst | local/R1 and Redis/R2 | +| Sliding-window log | exact but one sorted-set member per event; memory/CPU grows with volume | advanced opt-in | +| Sliding-window counter | approximate sliding window with bounded O(1) state | production option | +| Token bucket | independently controls average rate and burst capacity | recommended Redis default | +| Leaky bucket | smooth output; synchronous HTTP request queueing is not allowed | background/workflow opt-in | +| GCRA | precise smoothing with compact state | advanced opt-in | + +Policies are selected by stable `policyId`, not a global algorithm. A policy declares algorithm, +capacity/rate/window, burst, cost, failure mode, and subject dimensions. `Retry-After` comes from +the decision rather than the current fixed one-second value. + +When Redis fails, an optional emergency limiter is conservative, process-local, bounded in size and +TTL, and emits a degraded signal. It is not described as globally accurate. + +### 8.6 Session profile + +Authentication modes are exclusive: + +```text +jwt | redis-session +``` + +JWT remains the default: + +- stateless resource server; +- no session repository; +- CSRF may remain disabled for bearer-only APIs; +- Redis failure does not affect authentication. + +`redis-session` is opt-in: + +- real Spring Session Redis implementation on the separate session role; +- `IF_REQUIRED` session creation; +- CSRF enabled and tested; +- `Secure`, `HttpOnly`, `SameSite`, path/domain, expiry, and session-id rotation settings; +- logout deletes the server session; +- explicit serializer with versioning and allowed types; no JDK serialization; +- rolling-deploy compatibility test; +- multi-pod read/touch/expiry/logout contract; +- fail-closed repository behavior and readiness inclusion; +- optional indexed repository only when principal lookup/concurrent-session control is required. + +Login endpoints and identity proofing remain product decisions. The capability pack provides secure +session storage and web-security composition, not a guessed login business flow. + +### 8.7 Redis health and telemetry + +Required bounded metrics include: + +- cache get/load/invalidate outcome and duration by logical region; +- rate-limit decision/fallback by policy and algorithm; +- idempotency claim/replay/conflict/takeover/store operation; +- lock acquire/renew/lease-lost/release/fencing rejection; +- session repository operation/expiry/error; +- Redis command latency, timeout, connection pool saturation, reconnect, and server memory/eviction. + +No cache key, user, tenant, session ID, message ID, or lock resource is a metric tag. + +## 9. Idempotency design + +The current `find -> tryBegin` and scope-only completion API becomes one atomic ownership protocol: + +```text +claim(request) + -> ACQUIRED(ownerToken, leaseUntil) + -> REPLAY(storedResponse, replayUntil) + -> IN_PROGRESS(retryAfter) + -> FINGERPRINT_MISMATCH +``` + +Follow-up operations: + +```text +renew(ownerToken, newLeaseUntil) +complete(ownerToken, response, replayUntil) +release(ownerToken) +``` + +Rules: + +- execution lease and completed-response replay TTL are separate; +- completion and release compare the owner token; +- stale-owner operations return ownership-lost and cannot mutate a new claim; +- fingerprint canonicalization/version is explicit; +- stored response size, encryption/PII, codec version, and allowed replay metadata are bounded; +- mutations fail closed when the idempotency store is unavailable; +- JPA and Redis providers run the same contract suite; +- the sample module must demonstrate a POST genuinely invoking the executor rather than only + accepting an unused header. + +Guarantees are declared: + +- `REQUEST_REPLAY`: suppresses concurrent/repeated request execution as far as the store protocol + can observe; +- `SAME_STORE_TRANSACTIONAL`: the idempotency record and business change commit in the same + datastore transaction; +- `EXTERNAL_IDEMPOTENCY`: an outbound provider also receives a stable idempotency key. + +A Redis claim plus a JDBC business write is not `SAME_STORE_TRANSACTIONAL`. A crash after the +business write and before Redis completion can cause replayed execution. DB constraints, +intrinsically idempotent commands, outbox, compensation, or downstream idempotency keys remain +necessary. + +## 10. Lock and coordination design + +One mutex interface should not impersonate every coordination primitive. + +| Contract | Purpose | Correctness expectation | +| --- | --- | --- | +| `DistributedLockPort` / future `DistributedMutexPort` | reduce duplicate work or contention | efficiency only; DB constraints/invariants remain authoritative | +| `FencedLockPort` | prevent stale holders from writing | protected resource must reject lower fencing tokens | +| `LeaderElectionPort` | select an active coordinator | explicit leadership/lease lifecycle | +| `SemaphorePort` | bound distributed concurrency | permit ownership and expiry | +| `WorkClaimPort` | claim queue/jobs | claim token and visibility timeout | + +A Redis lease uses: + +- `SET key ownerToken NX PX lease`; +- owner-checked renew and release Lua scripts; +- bounded acquisition retry with jitter; +- maximum hold duration and bounded renewal count; +- lease-lost signal; +- idempotent release; +- optional atomic monotonically increasing fencing counter in the same cluster slot. + +Blind `DEL` is forbidden. An unlimited watchdog is forbidden. Fencing is useful only if the +database, object store, or downstream write port stores and rejects stale tokens. + +The fencing counter is separate from the expiring lease and never expires or resets. The protected +resource rejects a token lower than its accepted high watermark. Equality is accepted only for the +same owner token and lease epoch; a different owner must present a strictly greater token. If Redis +failover can lose an acknowledged increment, this provider cannot claim an R2 correctness +guarantee: acquisition fails closed/unready until a monotonic epoch above the protected resource's +recorded high watermark is established. A lock plus a best-effort Redis counter is still only an +efficiency mechanism. + +Provider guidance: + +- local provider: development/single-node only; +- JDBC table/advisory provider: low-rate coordination close to the primary DB; +- Redis provider: low-latency coordination with explicit failover limitations; +- a consensus system may be added later for stronger lease/election requirements; +- Kafka partition ownership is a work-distribution mechanism, not a generic mutex. + +Redis/Redlock is not advertised as a strong correctness guarantee. Network partitions, failover, +lease expiry, pauses, and wall-clock behavior require fencing or an authoritative invariant. + +## 11. Outbox, CDC, messaging, and inbox design + +### 11.1 What can and cannot be provider-neutral + +The outbox append must share the source-of-truth transaction: + +- a JPA/PostgreSQL business write appends to PostgreSQL; +- a Mongo business write appends to Mongo in the same supported transaction; +- moving the append to Redis or Kafka would lose atomicity unless a real distributed transaction is + introduced. + +The dispatch strategy is selectable: + +```text +disabled | polling | cdc +``` + +### 11.2 Immutable event plus delivery state + +Split the current mutable row: + +```text +outbox_event + immutable event envelope + +outbox_delivery + polling-only claim and delivery state +``` + +`outbox_event` contains: + +- event/message ID; +- event type and schema version; +- aggregate type, aggregate ID, and aggregate sequence/version; +- logical destination and partition key; +- content type and payload; +- occurred-at time; +- tenant when active; +- correlation and causation IDs; +- trace context allowlist. + +Physical Kafka topic names are adapter configuration. Application event types do not become topic +names by convention. + +Polling mode writes both rows in the business transaction and mutates only `outbox_delivery`. +Polling retains current short claim transactions, broker publish outside the DB transaction, +retry/backoff, aggregate ordering, orphan reclaim, and dead-letter behavior, with these additions: + +- claim owner token on every state transition; +- aggregate sequence rather than timestamp-only order; +- broker acknowledgement deadline; +- one combined retry budget across broker client and relay; +- operator replay/requeue/skip tooling and audit; +- explicit resolution for a dead event that blocks aggregate ordering. + +CDC mode: + +- writes only the immutable event row; +- does not create the Java polling scheduler or polling publisher; +- uses an externally deployed Kafka Connect/Debezium connector and Outbox Event Router; +- routes event ID to a header and aggregate/partition key to the broker key; +- owns connector predicate, schema mapping, offset, snapshot, WAL/replication-slot, retention, + restart, and recovery configuration; +- monitors connector lag, retained WAL, offset progress, serialization errors, and restarts; +- does not reuse polling `PUBLISHED` status or polling lag metrics. + +`outbox_event` is time/range partitioned for bounded retention. Cleanup may purge only a closed +partition whose high watermark is proven consumed by the connector checkpoint and whose replay +retention has elapsed. Cleanup never updates event rows, explicitly filters any cleanup +delete/tombstone records, alarms on table/partition growth, and is tested across connector outage, +restart, and snapshot cutover. + +Polling and CDC dispatch are mutually exclusive. Switching modes requires a runbook covering write +freeze or dual-read avoidance, backlog drain, connector offset verification, and rollback. + +### 11.3 Kafka producer baseline + +The messaging leaf gains a real client/provider: + +- acknowledgement-aware send result; +- `acks=all` and idempotent producer configuration; +- bounded delivery timeout and retry budget; +- stable key/partition ordering; +- compression/batch limits; +- TLS/SASL and secret references; +- schema serializer and compatibility validation; +- low-cardinality metrics and OTel propagation; +- readiness for required producer paths; +- graceful flush and shutdown. + +Kafka producer transactions are used only for Kafka-native workflows where their boundary applies, +such as consume-process-produce with committed offsets. They do not make a database write and Kafka +publish one atomic transaction. + +Best-effort `MessagePublisher` remains explicitly named and documented as best effort. Durable +business events use outbox. + +### 11.4 Kafka consumer and inbox + +Before consumer implementation, add an `adapter:inbound:messaging-kafka` leaf through the registry +migration workflow. Its production baseline includes: + +- manual acknowledgement after application success; +- handler/schema/version allowlist; +- bounded concurrency and queues; +- pause/resume backpressure; +- rebalance and `max.poll` handling; +- bounded retry topic or delayed-retry strategy; +- poison/deserialization failure classification; +- DLT plus audited replay tooling; +- trace context restoration; +- graceful drain and shutdown; +- consumer lag/rebalance/retry/DLT metrics. + +`InboxStorePort` scope is: + +```text +consumerGroup + handlerName + tenant? + messageId +``` + +For a handler that writes a database, inbox claim/completion and the business write commit in the +same database transaction. A Redis inbox may be a fast prefilter or serve a DB-free handler, but it +cannot claim same-store atomicity for a JDBC or Mongo write. + +End-to-end wording is: + +```text +at-least-once delivery + idempotent consumer/inbox +``` + +Redis Streams may be offered later as a smaller-scale messaging provider with consumer-group, +pending-entry, reclaim, trim, and dedupe contracts. It is not treated as a drop-in Kafka clone. + +## 12. Query and persistence design + +### 12.1 Query progression + +Keep feature-specific `*QueryPort` interfaces. Do not add one universal `QueryPort` or generic +repository. + +Supported progression: + +1. same-store aggregate read; +2. same-store projection via JPQL/JdbcTemplate/Mongo projection; +3. primary/read-replica routing; +4. separate read model populated through Kafka/CDC; +5. purpose-specific search or analytical store. + +Common application values: + +- opaque, signed, versioned cursor; +- bounded page size; +- allowlisted sort/filter; +- `ReadConsistency` such as `STRONG`, `READ_YOUR_WRITES`, `BOUNDED_STALENESS`, `EVENTUAL`; +- projection checkpoint and lag. + +`TransactionPort.inRead()` does not silently mean "use a replica." The query's consistency policy +and request context select primary or replica. Read-after-write flows remain on primary unless a +causal/checkpoint contract proves otherwise. + +### 12.2 JPA/PostgreSQL production baseline + +Preserve: + +- OSIV disabled; +- application-owned transaction port; +- Flyway migrations; +- persistence exception translation; +- current polling outbox, idempotency, and JDBC lock providers as selectable providers. + +Add: + +- explicit pool sizing, acquisition timeout, leak detection policy, and shutdown; +- statement/query/lock timeout hierarchy within the request deadline; +- batch write and fetch-size settings; +- N+1 detection and representative query-plan tests; +- optimistic version and bounded pessimistic-lock use; +- primary/read-replica routing with explicit consistency; +- migration expand/contract and rollback/roll-forward rules; +- tenant filter/index/unique-constraint rules when tenancy is active; +- slow query and pool saturation metrics; +- same-store inbox implementation; +- provider packages that make PostgreSQL-specific SQL visible and tested. + +Database-backed outbox/idempotency/lock remain valid providers. They stop being the only providers. + +### 12.3 MongoDB production baseline + +The Mongo leaf remains free of example business documents and gains reusable infrastructure: + +- typed URI/topology/TLS/credential/timeout/pool settings; +- explicit read preference, read concern, write concern, and transaction options; +- replica-set/sharded-cluster requirement validation for transactions/change streams; +- index manifest, unique/TTL indexes, drift detection, and migration runner; +- schema validation and optimistic versioning guidance; +- bounded query/page/time limits; +- retryable read/write classification; +- change-stream resume token/checkpoint store and oplog-window monitoring; +- same-store Mongo outbox/inbox option; +- real replica-set Testcontainers contract; +- rolling serializer/schema compatibility. + +Change streams are resumable only while the required oplog history and compatible pipeline/options +remain available. Pool sizing accounts for long-lived change-stream cursors. + +## 13. Remaining outbound capability baselines + +### 13.1 HTTP client + +The implementation-level authority for this capability is +[HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md). +This subsection remains the cross-capability baseline; where detail differs, the dedicated design +governs. + +Preserve the current connect/read timeout intent, bounded-response intent, retry/circuit-breaker +seams, shutdown guard, and diagnostics as characterization inputs, not as proven guarantees. The +dedicated audit shows that the current `globalCallTimeout` only gates whether another retry may +start; it does not actively bound or cancel DNS, pool wait, connect, TLS, write, response body, or +backoff. It also shows that the documented decorator order differs from the code. If every physical +attempt must affect circuit-breaker state, retry repeats a circuit-breaker-wrapped attempt; the +logical-call deadline and concurrency bulkhead remain outside that loop. If a provider intentionally +measures one logical call instead, that is a different named policy and test suite, not an accidental +wrapper-order side effect. + +Add: + +- named client registry with per-dependency settings; +- explicit connection pool total/per-route limits, acquisition timeout, idle eviction, DNS policy, + and graceful close; +- bulkhead and optional outbound rate limit; +- retry only for declared safe/idempotent operations, with exponential jitter and + `Retry-After` handling; +- a single total deadline covering pool wait, attempts, backoff, and body read, with active + cancellation of the engine call and response stream when the budget expires; +- redirect disabled by default or host-allowlisted; +- scheme/host/port/CIDR allowlist and DNS rebinding/SSRF defense; +- TLS trust, hostname verification, mTLS, proxy, and certificate rotation; +- request/response header and body-size allowlists; +- upload/download streaming and cancellation; +- OTel instrumentation owns trace propagation. Remove the manual `traceparent` writer when the + real tracer is active; +- failure injection and pool-exhaustion tests. + +Use cases continue to depend on feature-specific anti-corruption ports such as `RepoStatsPort`, not +on `OutboundHttpClient`. + +### 13.2 Notification + +Split technical routing from business consent/preferences. + +Application intent contains: + +- channel; +- logical template ID and version; +- locale; +- recipient reference/address; +- typed template parameters; +- delivery mode and idempotency key; +- correlation/tenant context. + +Application/domain policy owns consent, preference, and quiet-hour decisions when those are +business rules. The adapter owns: + +- real provider clients; +- template rendering/versioning/localization; +- priority/fallback/fan-out routing; +- provider quotas and bounded retry; +- dedupe and provider idempotency key; +- durable mode through outbox/message; +- webhook signature verification and delivery receipts through an inbound adapter; +- bounce/suppression handling; +- PII-safe logs, encrypted queue content, and retention; +- per-provider health and delivery outcome metrics. + +Critical notification is never routed through the current unconditional fail-open path. Best-effort +and durable interfaces are explicit. + +### 13.3 Object storage + +Replace whole-object `byte[]` as the only path with: + +- streaming upload/download and range reads; +- metadata/head contract; +- checksum algorithm/value contract and verification; +- conditional create/update/delete using version/ETag preconditions; +- presigned upload/download request with bounded expiry, content type, and size; +- multipart start/upload/complete/abort and orphan cleanup; +- server-side encryption and KMS settings; +- TLS/endpoint/region/credential-chain validation; +- lifecycle/versioning/retention policy checks; +- quarantine/malware-scan hook before publish; +- payload and metadata limits; +- metrics, tracing, and retry classification. + +Local filesystem and S3/MinIO pass the same semantic contract where the backend can support it. +Provider-specific optional capabilities are reported explicitly rather than silently emulated. + +Production defaults do not point to local MinIO, auto-create buckets, use static credentials, or +return internal filesystem paths to clients. + +Database state and object storage cannot share one local transaction. Workflows such as an image +attachment therefore use an explicit staged lifecycle: + +```text +stage upload -> verify checksum/scan -> commit attachment metadata -> finalize visibility +``` + +Failure paths use idempotent compensation plus an orphan reconciler with retention and audit +evidence. A use case must not perform an irreversible object write inside a database transaction +and assume rollback covers both systems. + +### 13.4 File server + +The authoritative implementation-level design for this capability is +[Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md). +This subsection is only the cross-capability baseline; the dedicated design governs when details +differ. + +Replace the current whole-file `StringBuilder` and direct overwrite with: + +- streaming row writer/iterator; +- temporary file in the target directory; +- restrictive creation permissions; +- flush/fsync file, atomic rename when supported, and directory fsync where required; +- explicit fallback when the filesystem cannot guarantee atomic move; +- no-follow-link and real-path containment checks; +- overwrite/precondition policy; +- checksum and manifest; +- size/row/disk-space quota; +- retention/reaper and partial-file cleanup; +- filename/extension/content policy; +- spreadsheet-formula injection defense for CSV/tabular exports, with a tested escaping policy; +- optional encryption and malware scan; +- NFS/SFTP-specific locking, visibility, and rename semantics documented as provider capabilities. + +An exported file is identified by an opaque receipt. Absolute server paths are not public API +values. + +### 13.5 Identifier and support + +`adapter:outbound:identifier` continues to implement domain/application identifier ports. It may +offer random UUID and time-ordered ID providers, but ordering, clock rollback, collision, encoding, +and database-index tradeoffs are explicit. Pseudonymization keys support secret rotation and never +become reversible identifiers. + +`adapter:outbound:support` remains a small home for provider-neutral outbound decorators and +diagnostic helpers. It does not become a miscellaneous infrastructure module. Fail-open decorators +classify expected dependency failures and do not swallow programming/codec/invariant defects. + +## 14. Inbound transport baselines + +### 14.1 Web + +Preserve current validation, error envelope, authz, pagination/cursor, conditional request, OpenAPI, +request correlation, and safe cache-control foundations. Add: + +- exclusive JWT/session authentication profiles; +- Redis-backed transport-edge rate-limit contract and policy registry; +- actual keyed idempotency executor integration; +- trusted-proxy chain validation; +- request/header/body/multipart limits; +- request deadline and cancellation propagation; +- graceful drain; +- stable API version/deprecation policy; +- OpenAPI compatibility gate; +- CSRF/session cookie tests for stateful mode; +- route-level security/rate/idempotency capability declarations. + +### 14.2 GraphQL + +Production baseline: + +- shared authentication/tenant context; +- operation and field authorization; +- parser character/token/rule-depth limits; +- query depth and cost/complexity instrumentation; +- persisted-query allowlist profile; +- DataLoader/batch-loader convention and N+1 contract; +- cursor connection and bounded page policy; +- sanitized error extensions; +- introspection/GraphiQL production policy; +- schema snapshot/breaking-change check; +- query duration/complexity/error metrics; +- subscription transport delegated to an explicitly designed WebSocket/messaging path. + +### 14.3 gRPC + +Production baseline: + +- protobuf generation/versioning convention and compatibility check; +- TLS/mTLS and service/method authorization interceptors; +- required client deadlines and server cancellation propagation; +- request/response and metadata size limits; +- retry policy only for suitable status/method semantics; +- keepalive coordinated with infrastructure; +- unary and streaming backpressure/cancellation; +- standard health status updated during startup/drain/shutdown; +- reflection opt-in outside production; +- graceful shutdown and in-flight drain; +- OTel RPC semantic spans and bounded metrics. + +### 14.4 WebSocket + +The simple in-memory STOMP broker remains local/R1 only. + +Production baseline: + +- authenticated handshake and re-auth/session-expiry behavior; +- destination-level subscribe/send authorization; +- trusted origins and payload/frame limits; +- heartbeat and idle timeout; +- bounded inbound/outbound executors, queues, send time, and an explicit disconnect/drop policy; +- sequence/resume contract where message loss matters; +- per-session ordering only when required and measured; +- broker relay or a durable integration-event bridge for multi-node delivery; +- broker availability/readiness and graceful disconnect; +- no direct serialization of arbitrary domain events to public destinations. + +Cross-node durable live updates consume an integration/presentation event. The in-process Spring +event bus is not a durable or cluster-wide transport. + +## 15. Observability and operational safety + +### Signals + +- Traces: inbound server, application use case, DB/Redis, messaging producer/consumer, HTTP, object + storage, notification, and background-worker spans with standard semantic conventions. +- Metrics: request/dependency latency, errors, saturation, backlog/lag, lease loss, retry, DLT, + cache behavior, and provider lifecycle. +- Logs: stable structured schema correlated with trace/span IDs. +- Audit: a separate durable, access-controlled record for security/business actions; not ordinary + application logs. + +Instrumentation uses one context-propagation owner per transport. Payloads, tokens, Redis keys, +session IDs, raw principals, email addresses, and object names are not added to metrics and are +allowlisted or pseudonymized in logs/traces. + +Production tracing includes a configured OTLP exporter and batch span processor; tests use an +in-memory exporter to prove spans and propagation rather than treating a registry entry as emitted +telemetry. Metrics similarly prove recording, tags, and cardinality. Unmatched or templating-failed +HTTP requests use a fixed route label such as `UNKNOWN`, never a raw URI. + +### Health + +| Probe | Rule | +| --- | --- | +| Liveness | JVM/process ability only; never DB, Redis, Kafka, SMTP, object storage, or HTTP dependencies | +| Readiness | enabled providers marked required for this deployment | +| Component health | every enabled provider, including optional cache and notification | +| Startup | configuration, migration, script/schema compatibility, and required topology validation | + +Optional cache failure does not restart or necessarily unready the pod. Session, strict +idempotency, required lock, or required message publisher failure can make the application +unready. The capability descriptor decides; bean name presence does not. + +### Capacity and runbooks + +Each R2/R3 capability includes capacity inputs rather than fabricated numbers: + +- key/message/session/object size; +- operation rate and concurrency; +- retention/TTL; +- retry amplification; +- connection/thread/partition counts; +- replica/failover expectations; +- alert thresholds derived from an actual SLO. + +Required runbooks cover backlog, lag, DLT, stale lease, Redis memory/noeviction, session outage, +connector slot/WAL growth, index drift, multipart orphan, disk capacity, certificate expiry, and +provider credential rotation. + +## 16. Gradle and dependency design + +Rules: + +- `domain-core`, `application-core`, and `shared-contract` keep project-only production + dependencies and no Spring starter/SDK. +- A real provider dependency lives only in its owning adapter leaf. +- `implementation` is the default. `api` is used only when a public contract intentionally exposes + a third-party type, which these ports generally forbid. +- Spring Boot-managed coordinates use the Boot BOM. Non-Boot SDKs import a provider BOM at module + scope, following the existing gRPC/AWS pattern. +- Dependency locks and verification metadata change in the same implementation slice as the + dependency. +- Testcontainers, Toxiproxy, embedded brokers, and schema test tools remain test/integration-test + dependencies. +- Provider contract kits use Gradle test fixtures or a dedicated test-support source set without + becoming production dependencies. +- Integration tests receive a separate `integrationTest` task per provider; architecture checks + remain part of `check`. +- Do not introduce a version catalog solely for this work. The existing BOM/module pin model can be + retained until dependency ownership itself becomes hard to maintain. +- Tighten registry edges after implementation. Do not add speculative adapter-to-adapter edges. + +Expected dependency ownership: + +| Dependency family | Owner | +| --- | --- | +| Spring Data Redis/Lettuce and Spring Session Redis | Redis provider leaf | +| Kafka client/Spring Kafka producer | outbound messaging leaf | +| Kafka listener runtime | future inbound messaging leaf | +| Debezium/Kafka Connect | deployment/integration-test assets, not application-core | +| Mongo driver/Spring Data Mongo | Mongo persistence leaf | +| AWS S3 SDK | object-storage leaf | +| Resilience4j/HTTP engine | HTTP-client leaf | +| gRPC/protobuf runtime/build tooling | gRPC leaf | +| OTel/Micrometer exporter/composition | bootstrap and provider instrumentation adapters | + +The new Kafka inbound leaf is the only module addition proposed as structurally necessary in this +design. It requires an explicit `modules.json`, settings, Gradle dependency-gate, documentation, +and architecture-test migration rather than bypassing the exact-19 assertion. + +## 17. Verification and CI design + +### Test layers + +| Layer | Purpose | +| --- | --- | +| Pure unit | policy, algorithms, codec/version/key rules, retry/deadline math | +| Port contract | common required-semantics suite plus guarantee/capability-specific provider suites | +| Real-service integration | Redis, PostgreSQL, Mongo replica set, Kafka, MinIO, provider sandbox | +| Concurrency | duplicate claim, token spend, stale release, ordering, session sharing | +| Failure injection | timeout, disconnect, pool exhaustion, restart, failover, network partition | +| Compatibility | serialization, schema, migration, rolling version, Redis/Kafka/Mongo version | +| Architecture | SDK/type/dependency direction and optional-provider gating | +| Operational | health, metrics cardinality, trace propagation, secret/PII absence, graceful shutdown | + +Redis tests include standalone and cluster slot behavior, script reload, token mismatch, maxmemory +separation, and multiple client connections. CDC tests run PostgreSQL, Kafka, Kafka Connect/Debezium +end to end. Session tests use two application contexts against one Redis service. + +Providers in different guarantee classes are never certified as semantically identical. For +example, local/JDBC/Redis locks and filesystem/S3 storage share only the required contract subset; +fencing, conditional writes, multipart, durability, and failover claims require their own +capability suite. + +### CI profiles + +- PR gate: unit, architecture, provider contract, and one supported real-service baseline. +- Production-readiness gate: Docker/services are required; absence is a failure, not a silent skip. +- Nightly/weekly matrix: supported datastore/broker versions, cluster/failover, rolling + serialization, Toxiproxy, and longer concurrency/soak tests. +- Optional provider sandbox tests use explicit credentials and remain separated from deterministic + local protocol tests. +- Performance tests establish product-specific budgets later. This design requires load-test + hooks and capacity metrics, not generic benchmark claims. + +## 18. Phased implementation roadmap + +### Phase 0 — Correctness contracts and truthful capability topology + +- replace bean-name multi-instance checks with typed provider descriptors; +- remove or correct registry/settings claims for capabilities that do not exist; +- reconcile notification and JWT keys across the environment registry, YAML, typed settings, and + actual conditional beans; +- establish uniform explicit module/provider activation and prohibit missing-property activation + of local, plaintext, reflection, or auto-create defaults; +- publish a truthful default bootstrap capability manifest instead of equating source modules with + composed runtime features; +- correct and contract-test HTTP retry/circuit-breaker ordering and make the total deadline cancel + in-flight work; +- revise idempotency around owner token, lease, and replay TTL; +- distinguish efficiency lock, fenced lock, leadership, semaphore, and work claim; +- define capability cards, readiness levels, settings prefix, failure policy, and common contract + test kit; +- fix the local fixed-window key lifecycle or mark it dev-only with bounded storage; +- keep application-core dependency purity and all architecture gates green. + +Acceptance: the template cannot start in a configuration that claims an unavailable or weaker +provider guarantee. + +### Phase 1 — Real Redis foundation and cache + +- real Spring Data Redis/Lettuce client; +- cache/coordination/session role settings and connections; +- TLS/ACL/timeouts/pool/topology/health; +- versioned codec/key schema; +- cache get/put/evict/bulk/TTL/negative result; +- cache-aside, jitter, single-flight, after-commit invalidation; +- Redis standalone/cluster/failure/observability tests. + +Acceptance: cache reaches R2 while correctness Redis roles remain inactive unless selected. + +### Phase 2 — Distributed rate limit, idempotency, locks, and sessions + +- shared transport-edge rate-limit contract plus fixed/sliding-counter/token-bucket Lua providers; +- policy registry and emergency fallback; +- Redis/JPA idempotency contract implementations; +- JDBC/Redis lock provider selection, owner-safe renew/release, fenced lock; +- JWT/Redis-session exclusive profiles and multi-pod session contract. + +Acceptance: each selected provider has explicit guarantees and failure behavior; no correctness data +uses the evictable cache role. + +### Phase 3 — Kafka, polling outbox evolution, inbox, and CDC + +- acknowledgement-aware real Kafka producer; +- immutable `outbox_event` plus polling `outbox_delivery`; +- claim token and aggregate sequence; +- add inbound Kafka leaf and inbox executor/provider; +- retry/DLT/replay/backpressure/graceful lifecycle; +- Debezium connector/deployment assets and end-to-end CDC profile; +- polling/CDC exclusivity and transition runbook. + +Acceptance: both dispatch modes independently satisfy at-least-once delivery and idempotent-consumer +contracts without an exactly-once claim. + +### Phase 4 — HTTP, notification, object storage, and file server + +- complete HTTP pool/bulkhead/SSRF/TLS/OTel baseline; +- durable notification intent, provider routing, templates, receipts; +- streaming/multipart/presigned/checksum/encryption storage contracts plus staged finalization, + compensation, and orphan reconciliation; +- atomic streaming file exports, quotas, retention, CSV formula defense, and + filesystem-provider semantics. + +Acceptance: each adapter has a real R2 provider, failure injection, health, metrics, and a capability +card. + +### Phase 5 — JPA/Mongo query models and inbound transports + +- read consistency and replica routing; +- same-store and separate read-model profiles with checkpoint/lag; +- Mongo concerns/indexes/migrations/transactions/change streams; +- GraphQL complexity/DataLoader/schema gates; +- gRPC TLS/auth/deadline/streaming/proto gates; +- WebSocket broker relay/backpressure/auth/cluster behavior; +- route/operation capability declarations across transports. + +Acceptance: query and transport choices are explicit and operationally observable without leaking +transport or persistence types into core. + +### Phase 6 — R3 scale and extraction review + +- failover, rolling upgrade, version matrix, recovery drills, and capacity runbooks; +- evaluate splitting Redis capability-provider leaves; +- evaluate extracting a platform BOM/starter only after multiple real consumers validate the APIs. + +## 19. Completion criteria for the future implementation + +The implementation is complete only when: + +- every enabled capability has a real provider rather than a project-supplied seam; +- provider selection is exact, typed, and fail-fast; +- the provider guarantee and non-guarantees are visible; +- unused capabilities have no runtime side effects; +- core modules remain framework/SDK-free; +- every provider passes reusable contract plus real-service/failure tests; +- correctness and optimization data stores are separated where eviction/failure semantics differ; +- health, metrics, traces, logs, graceful lifecycle, security, and runbook are present; +- CI has a non-skipping production-readiness path; +- no cross-database exactly-once or strong Redis-lock claim appears in code or documentation. + +## 20. Primary references + +- [Redis scripting and atomic blocking semantics](https://redis.io/docs/latest/develop/programmability/eval-intro/) +- [Redis rate-limiter use case and algorithm options](https://redis.io/docs/latest/develop/use-cases/rate-limiter/) +- [Redis key eviction](https://redis.io/docs/latest/develop/reference/eviction/) +- [Redis distributed locks and fencing guidance](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/) +- [Spring Data Redis scripting](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html) +- [Spring Session Redis APIs](https://docs.spring.io/spring-session/reference/api.html) +- [Debezium Outbox Event Router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html) +- [Apache Kafka delivery semantics and transactions](https://kafka.apache.org/42/design/design/) +- [Apache Kafka producer configuration](https://kafka.apache.org/41/configuration/producer-configs/) +- [Resilience4j fault-tolerance primitives](https://resilience4j.readme.io/docs/getting-started) +- [Amazon S3 object-integrity checks](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html) +- [MongoDB read concern](https://www.mongodb.com/docs/manual/reference/read-concern/) +- [MongoDB write concern](https://www.mongodb.com/docs/manual/reference/write-concern/index.html) +- [MongoDB change streams](https://www.mongodb.com/docs/manual/changestreams/) +- [GraphQL Java query limits](https://graphql-java.com/documentation/limits/) +- [gRPC deadlines](https://grpc.io/docs/guides/deadlines/) +- [gRPC retry](https://grpc.io/docs/guides/retry/) +- [Spring WebSocket external broker relay](https://docs.spring.io/spring-framework/reference/6.2/web/websocket/stomp/handle-broker-relay.html) +- [OpenTelemetry signals and semantic conventions](https://opentelemetry.io/docs/concepts/) diff --git a/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md b/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md new file mode 100644 index 0000000..9e3e1de --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md @@ -0,0 +1,6771 @@ +# Redis Production Capability Deep Design + +- Date: 2026-07-26 +- Status: 상세 설계 완료, Phase 0 및 Phase 1 일부 standalone R1 구현, R2 미구현 +- Scope: Redis 전용 production capability와 단계적 구현 설계 +- Baseline: Java 21, Spring Boot 4.0.0, Gradle multi-module Clean Architecture template +- Parent: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) + +## 0. 구현 상태 + +2026-07-28 기준 구현된 범위: + +- `application-core`의 provider-neutral `CacheRegionPort`와 hit/negative/miss/schema/unavailable + 결과 구분; +- source revision과 application consistency intent를 담는 record/invalidation outcome; +- namespace, key/hash version, single hash slot, bounded digest를 고정하는 `RedisKeyBuilder`; +- opaque ID용 SHA-256 및 민감한 composite scope용 length-prefixed HMAC-SHA-256; +- `compare-and-delete-v1`, `compare-and-expire-v1`, `set-if-absent-with-ttl-v1` Lua resource; +- exact SHA-256와 status/signature를 기록한 R0 `program-set.json`; +- generic application API가 아닌 package-private `RedisAtomicPrimitives` internal R0 foundation과 + compatibility failure; +- managed Lettuce standalone connection lifecycle과 finite command timeout; +- `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 `EVAL` fallback하는 production executor; +- versioned digest-protected bounded binary cache envelope, positive/negative TTL, invalidate와 + corrupt/future/unavailable 구분을 제공하는 `CacheRegionPort` reference adapter; +- HMAC key secret/namespace/value bound typed settings와 disabled zero-connection composition; +- `managed`/`external` client mode를 통한 결정적 runtime 선택; +- reconnect command replay 차단, finite Lettuce request queue와 client-side admission; +- bounded Lua `GETRANGE` read로 wire bulk reply를 envelope maximum + 1 byte로 제한하고 + oversized 외부 value를 typed incompatible schema로 격리; +- managed runtime 활성화 시 Redis host 누락을 `localhost`로 숨기지 않는 startup fail-fast; +- generic Lua executor/descriptor와 raw-key typed primitive를 package-private collaborator로 + 닫고 Spring composition에는 semantic cache port만 노출; +- 명시적 Redis 7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua, + oversized bulk-reply 차단 검증. + +아직 구현되지 않은 범위: + +- cache jitter, soft/hard TTL, cache-aside/single-flight/source bulkhead; +- Redis Functions 배포와 program upgrade/rollback compatibility matrix; +- health/metrics/TLS/ACL/secret/topology/eviction 검증; +- distributed rate limit, idempotency, lease/fencing, session; +- Phase 1의 전체 acceptance와 R2/R3 승격 증거. + +따라서 standalone runtime/string cache는 R1 evidence를 가지지만 Redis capability 전체 또는 +어떤 production topology도 R2가 아니다. raw-key Lua foundation과 +rate/idempotency/lease/session은 semantic composition이 없어 여전히 R0다. + +## 1. 설계 판정 + +설계 착수 당시 `adapter:outbound:cache-redis`는 실제 Redis client, connection, topology, TTL, +codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-28 구현으로 +standalone managed Lettuce runtime과 semantic string cache는 R1까지 올라왔지만, topology, +TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-ready adapter는 아니다. + +이번 설계는 다음 구조를 선택한다. + +1. 정확히 19개인 현재 leaf registry는 우선 유지한다. +2. 물리 모듈 `adapter:outbound:cache-redis`는 R2 단계에서 Redis technology capability + provider로 확장한다. +3. application/domain에는 범용 `RedisPort`, raw key, command, Lua, `RedisTemplate`, Lettuce, + Spring Session 타입을 노출하지 않는다. +4. cache, edge rate limit, idempotency, efficiency lease, fenced coordination, session은 서로 + 다른 semantic contract와 failure policy를 가진다. +5. 최소한 `cache`, `coordination`, `session` Redis role을 서로 다른 deployment로 격리한다. +6. Redis의 command 직렬 실행, Lua/Function atomicity, `WAIT`, AOF, Sentinel, Cluster를 + cross-store exactly-once나 strong correctness lock으로 표현하지 않는다. +7. 안전한 Redis operation은 versioned program catalog로 제공하되 application이 임의 command나 + script를 실행하게 하지 않는다. +8. 운영 profile은 real client, bounded resources, TLS/ACL, health, metrics, failure injection, + topology test, runbook까지 갖춰야 R2/R3로 표시한다. + +현재 상태와 목표는 다음과 같다. + +| Capability | 현재 | 목표 | +| --- | --- | --- | +| Redis runtime | managed Lettuce standalone R1 + explicit external-client mode | Spring Data Redis + Lettuce 기반 typed runtime | +| Cache | `Optional get`, `void put` | typed region, TTL, negative/stale, invalidate, cache-aside | +| Rate limit | inbound-web single-node fixed window | policy별 fixed/sliding/token/GCRA Redis provider | +| Idempotency | JPA 전제, owner token 없음 | atomic claim, owner-safe complete, execution/replay TTL 분리 | +| Lock | JDBC efficiency lock | Redis efficiency lease + 별도 fenced contract | +| Session | JWT stateless 고정 | JWT 또는 isolated Redis Session의 명시적 profile | +| Atomic helper | 없음 | versioned Function/Lua program registry | +| Topology | 없음 | standalone, Sentinel, Cluster의 typed exclusive profile | +| Failure | 모든 cache exception을 miss로 변환 | capability별 fail-open/closed/degraded/indeterminate | +| CI | fake unit test | real Redis, topology, concurrency, failure, compatibility matrix | + +설계가 완료되었다는 뜻은 구현 계약과 단계가 결정되었다는 뜻이다. 현재 Redis runtime이 +production-ready가 되었다는 뜻은 아니다. + +## 2. 기존 통합 설계와 이번 심화 설계의 관계 + +상위 통합 설계는 다음 결정을 이미 내렸다. + +- semantic port와 provider를 분리한다. +- cache, coordination, session Redis role을 격리한다. +- cache fail-open을 correctness capability에 재사용하지 않는다. +- rate-limit algorithm을 policy별로 선택한다. +- idempotency와 lock에 owner token과 fencing을 도입한다. +- Redis SDK나 raw command를 core에 노출하지 않는다. + +이번 문서는 그 결정을 실제 구현자가 임의로 해석하지 않도록 다음을 추가로 고정한다. + +- 현재 코드의 정확한 결함과 제거 순서; +- physical module 유지와 향후 split 조건; +- application/shared/web/bootstrap의 소유권; +- role, deployment, connection, key, codec, program의 구체 계약; +- cache lookup/write/invalidation 결과 모델; +- cache-aside, negative cache, stale, refresh, L1/L2, invalidation 전략; +- rate-limit algorithm별 상태, 비용, 원자성, fallback; +- lease, renewal, lost state, fencing, unknown outcome; +- Redis idempotency state machine과 cross-store 한계; +- Spring Session profile, serializer, expiry, concurrent mutation, logout; +- standalone/Sentinel/Cluster, replication, persistence, eviction의 실제 보장; +- client reconnect/replay, timeout, queue, pool, backpressure; +- security, observability, health, graceful shutdown, runbook; +- real-service, concurrency, failover, memory, compatibility CI. + +세부 내용이 상위 문서의 Redis 요약과 다를 경우 이 Redis 전용 문서가 Redis 범위의 정본이다. +상위 문서의 다른 capability 결정은 변경하지 않는다. + +### 2.1 Normative decision ledger + +긴 문서에서 결정을 다시 추론하지 않도록 구현과 리뷰는 다음 정본 위치를 사용한다. + +| 결정 | 정본 | +| --- | --- | +| capability/provider 소유권과 모듈 경계 | §7–§8 | +| readiness/guarantee 용어 | §9 | +| role/deployment 격리 | §10, §27–§29 | +| key/codec/program manifest | §11–§13 | +| common primitive와 자료구조 안전 기준 | §14 | +| cache 계약과 source 결과 | §15–§18 | +| rate-limit 알고리즘과 결과 | §19–§21 | +| lease/fencing과 idempotency | §22–§24 | +| Redis Session 보안 계약 | §25 | +| client/replay/timeout | §31 | +| 설정·activation SSOT | §32–§33 | +| secret material과 ACL | §34 | +| CI task/lane/evidence | §37 | +| dependency ownership | §38 | +| 단계별 readiness 승격 | §39–§40 | + +표의 링크 대상보다 예시 YAML이나 migration alias가 우선하지 않는다. 상충하는 두 설정이 +존재하면 임의 precedence를 선택하지 않고 startup을 실패시킨다. + +## 3. 증거 기반 현재 상태 + +### 3.1 실제 Redis client가 없다 + +현재 leaf의 production dependency는 다음뿐이다. + +```text +shared-contract +adapter:outbound:support +spring-boot-autoconfigure +slf4j-api +``` + +Spring Data Redis, Lettuce, Jedis, Redisson 중 어떤 runtime도 없다. + +`RedisCacheAdapterConfig`는 `app.cache.redis.enabled=true`이면 `RedisClient` bean을 요구하지만, +production 구현은 없다. 테스트가 anonymous fake를 주입해서 bean gating만 확인한다. 따라서 현재 +enable flag는 “Redis가 동작한다”가 아니라 “forking project가 별도 client를 구현했을 때 seam을 +기여한다”는 뜻이다. + +### 3.2 application이 소비할 합법적인 port가 없다 + +`CacheStore`, `CacheBackend`, `CacheStoreRouter`는 모두 outbound adapter 내부 타입이다. +`application-core`는 adapter leaf에 의존할 수 없으므로 use case가 이 router를 합법적으로 주입받을 +수 없다. production consumer 검색 결과도 0개이며 bootstrap test만 router를 사용한다. + +이는 cache code가 존재하지만 Clean Architecture의 실제 outbound port가 존재하지 않는 상태다. + +### 3.3 logical region이 physical key에 반영되지 않는다 + +현재 router는 `logicalName`으로 backend만 선택하고 backend에는 raw `key`만 전달한다. + +```text +router.get("worklog", "42") -> redis.get("42") +router.get("codes", "42") -> redis.get("42") +``` + +두 region이 같은 backend를 사용하면 충돌한다. application, environment, tenant, capability, +region, key schema version도 구분되지 않는다. session이나 idempotency를 같은 backend에 +연결한다면 더 치명적이다. + +### 3.4 cache lifecycle을 표현할 수 없다 + +현재 계약에는 다음이 없다. + +- positive/negative TTL; +- soft/hard TTL; +- TTL jitter; +- invalidate/delete; +- conditional write; +- bulk get/evict; +- namespace generation; +- schema version; +- payload size; +- corruption outcome; +- after-commit invalidation; +- stampede suppression; +- stale-if-error. + +특히 `put(key, value)`에 TTL이 없으므로 단순 `SET` 구현은 immortal cache를 만든다. + +### 3.5 장애와 miss가 합쳐진다 + +`FailOpenCacheStore`는 모든 backend를 중앙에서 감싸고 모든 `Exception`을 다음처럼 처리한다. + +```text +get failure -> Optional.empty() +put failure -> swallow +``` + +따라서 정상 miss, timeout, connection failure, wrong-type, corrupt payload, codec bug, +programming defect를 caller가 구분할 수 없다. 성능 최적화용 cache의 일부 장애에는 fail-open이 +가능하지만, codec bug까지 miss로 숨기는 것은 장애 증폭과 source overload를 만든다. session, +idempotency, strict rate limit, lock에 이 decorator를 재사용하는 것은 금지한다. + +### 3.6 multi-instance mode는 현재 조립할 수 없다 + +`APP_MULTI_INSTANCE_ENABLED=true`는 bean name으로 다음 다섯 개를 요구한다. + +```text +distributedLockProvider +cacheStampedeProtection +outboxLeaderElection +distributedRateLimiter +migrationStartupRunner +``` + +현재 production composition에는 `cacheStampedeProtection`과 `distributedRateLimiter` 두 bean이 +없고 test configuration만 다섯 이름의 plain `Object`를 제공한다. 그러므로 현재 실제 composition은 +multi-instance mode에서 반드시 startup failure가 난다. 더 큰 문제는 단순 bean name 검사가 +provider topology나 guarantee를 검증하지 않는다는 점이다. + +### 3.7 rate limit은 fixed-window local map 하나다 + +현재 `RateLimitAlgorithm`은 `FIXED_WINDOW`만 제공한다. `FixedWindowRateLimiter`는 process-local +`ConcurrentHashMap`을 사용하므로 pod마다 quota가 따로 존재하고 key removal policy가 없다. + +추가 결함은 다음과 같다. + +- authenticated key에 route/policy dimension이 없다. +- raw principal/IP를 Redis key로 옮길 위험이 있다. +- window boundary concurrency contract가 없다. +- `Retry-After`가 decision이 아니라 고정 1초에 묶여 있다. +- invalid limit/window가 fail-fast하지 않고 default로 조용히 바뀐다. +- key cardinality budget과 cleanup이 없다. + +### 3.8 idempotency와 lock port는 Redis provider를 안전하게 수용하지 못한다 + +현재 `IdempotencyStorePort`는: + +```text +find(scope) +tryBegin(scope, fingerprint, expiresAt) +complete(scope, response) +discard(scope) +``` + +형태다. `complete`와 `discard`에 owner token이 없어 old owner의 lease가 만료된 후 new owner가 +claim해도 stale owner가 새 record를 덮어쓰거나 삭제할 수 있다. processing lease와 completed +response replay TTL도 하나의 TTL로 합쳐져 있다. + +현재 `DistributedLockPort`는 acquired handle의 `close()`만 제공한다. owner token, renewal, +lease-lost, current validity, fencing token, acquire/release의 unknown outcome을 표현하지 못한다. +문서상 efficiency lock인 점은 올바르지만 Redis provider를 붙일 계약으로는 불충분하다. + +### 3.9 configuration registry와 runtime이 어긋난다 + +registry에는 Redis host, port, password, cache TTL 관련 key가 있지만 typed settings와 실제 +client 소비자는 없다. `application.yml`에는 사실상 enabled flag만 있다. + +빠진 운영 설정은 다음과 같다. + +- standalone/Sentinel/Cluster; +- endpoint discovery와 DB index; +- TLS와 hostname verification; +- ACL username/password secret reference; +- connect/command/acquire/overall timeout; +- request queue와 pool bound; +- topology refresh와 redirect limit; +- primary/replica read policy; +- client name; +- shutdown/drain; +- role별 required/readiness; +- server/program/schema compatibility. + +### 3.10 현재 test가 증명하는 범위 + +현행 focused test는 통과한다. + +```text +./gradlew :adapter:outbound:cache-redis:test --rerun-tasks --console=plain +19 tests, failures/errors/skipped 0 +``` + +이 test는 fake seam의 routing과 fail-open 동작을 증명한다. 실제 Redis command, TTL, Lua, +eviction, replication, failover, Cluster slot, TLS/ACL, session, concurrency는 증명하지 않는다. + +## 4. 범위와 명시적 비범위 + +### 4.1 R2 baseline에 포함 + +- Spring Data Redis + Lettuce real provider; +- standalone과 managed/Sentinel topology의 production profile; +- Cluster-compatible key/program 설계; +- cache, coordination, session role 분리; +- typed settings와 startup validation; +- versioned key builder와 codec; +- versioned Function/Lua program catalog; +- cache-aside, negative cache, TTL jitter, invalidate; +- soft/hard TTL과 stale-if-error; +- local single-flight와 optional Redis refresh lease; +- fixed window, sliding counter, token bucket rate limiter; +- owner-safe Redis efficiency lease; +- owner-safe Redis idempotency request replay profile; +- Redis-backed Spring Session profile; +- TLS, ACL, secret rotation contract; +- capability metrics, health, traces, logs; +- real Redis, concurrency, memory, failure integration test; +- explicit provider selection and no side effect when disabled. + +### 4.2 R2에서 열어둘 advanced operation + +- exact sliding-window log; +- GCRA; +- L1 local cache + Redis L2; +- probabilistic early refresh; +- refresh-ahead; +- client-side tracking; +- Pub/Sub cache invalidation hint; +- namespace generation invalidation; +- fenced lease; +- bounded distributed semaphore; +- leader election; +- indexed Spring Session repository; +- Redis Function provisioning mode; +- `WAIT`/`WAITAOF` acknowledgement profile; +- replica reads for explicitly stale-tolerant cache; +- compression; +- multi-region cache warming. + +각 advanced operation은 enable만으로 R2가 되지 않는다. 별도 capability card와 contract test가 +필요하다. + +### 4.3 R3에서 검증할 항목 + +- Redis Cluster reshard와 rolling topology change; +- Sentinel/Cluster failover under partition; +- serializer/key/program rolling compatibility; +- supported Redis version matrix; +- credential/certificate rotation without global outage; +- persistence recovery와 declared RPO 검증; +- capacity/latency soak; +- fenced consumer의 실제 stale-token rejection; +- multi-region topology와 region failover. + +### 4.4 비범위 + +- 모든 future domain을 위한 universal repository; +- raw Redis data-structure facade를 application에 제공; +- 임의 Lua/Function source 실행 API; +- Redis를 authoritative relational database처럼 사용; +- generic cross-store transaction; +- exactly-once side effect 보장; +- Redis lock만으로 business invariant 보장; +- synchronous HTTP leaky-bucket queue; +- Redis Pub/Sub을 durable business event bus로 사용; +- Redis Streams를 현재 messaging leaf에 암묵적으로 추가; +- 실제 workload 없이 단일 maxmemory, pool size, timeout, TPS를 정답으로 고정; +- Redis server의 deployment IaC 전체 구현. + +## 5. HARD invariants + +구현은 다음 조건을 모두 지켜야 한다. + +1. `domain-core`에는 Redis, cache, session, rate-limit 기술 개념이 없다. +2. `application-core`에는 Spring, Lettuce, Redis command, key syntax, Lua, serializer SDK가 없다. +3. application use case는 `RedisTemplate`, connection, raw command executor를 받지 않는다. +4. transport rate limit과 business quota를 같은 port로 합치지 않는다. +5. session repository를 application port로 추상화하지 않는다. +6. cache miss와 backend unavailable을 같은 결과로 합치지 않는다. +7. codec/schema/programming 오류는 fail-open miss로 숨기지 않는다. +8. correctness capability는 evictable cache role에 bind하지 않는다. +9. Redis database number와 prefix를 workload isolation으로 간주하지 않는다. +10. expirable write는 value write와 TTL을 한 atomic command/program에서 수행한다. +11. lock release/renew는 owner token을 비교한다. blind `DEL`/`PEXPIRE`는 금지한다. +12. idempotency complete/release는 owner token과 claim revision을 비교한다. +13. multi-command read/decide/write를 “Redis가 single-thread이므로 안전”하다고 설명하지 않는다. +14. Lua/Function은 bounded complexity와 bounded state growth를 가져야 한다. +15. program은 classpath/provisioned artifact로 version/checksum이 고정된다. +16. runtime caller가 동적 script source나 key name을 programmatically 생성하지 않는다. +17. Cluster multi-key atomic operation은 같은 slot임을 key builder와 test가 보장한다. +18. timeout/reset 후 mutation 결과를 자동으로 `FAILED`라고 단정하지 않는다. +19. non-idempotent mutation을 결과 확인 없이 무조건 retry하지 않는다. +20. `WAIT`, `WAITAOF`, AOF, replica를 strong consistency나 zero-loss로 표현하지 않는다. +21. Redis lease를 fencing 없는 correctness lock으로 표현하지 않는다. +22. Pub/Sub/keyspace notification을 durable invalidation이나 expiry source of truth로 사용하지 않는다. +23. cache DB update와 Redis update가 atomic하다고 표현하지 않는다. +24. raw PII, credential, token, session ID, idempotency key를 Redis key/log/metric tag에 넣지 않는다. +25. regular request path에서 `KEYS`, unbounded `SCAN`, unbounded collection read를 실행하지 않는다. +26. unused capability는 connection, thread, scheduler, health dependency를 만들지 않는다. +27. `@Primary`, bean-name 존재만으로 provider와 guarantee를 선택하지 않는다. +28. provider cutover 중 JDBC와 Redis가 동시에 같은 scope를 독립 claim하게 하지 않는다. +29. liveness를 Redis availability에 연결하지 않는다. +30. real Redis/failure test 없이 R2/R3를 주장하지 않는다. + +## 6. 대안 검토 + +### A. 현재 `RedisClient` seam에 method만 계속 추가 + +장점은 change surface가 작다는 것이다. 그러나 host SDK를 다시 추상화하는 거대한 low-level +interface가 되고 Redis semantics를 fake test로 흉내 내게 된다. Cluster redirect, Lua result, +timeout certainty, connection lifecycle을 새 interface가 부정확하게 복제한다. + +선택하지 않는다. + +### B. application에 범용 `RedisPort` 제공 + +예를 들어 `get/set/incr/zadd/eval`을 application port로 노출하면 개발자는 빠르게 기능을 만들 수 +있다. 대신 use case가 provider key, TTL, serialization, data structure, atomic recipe를 직접 +소유하고 Clean Architecture 경계가 무너진다. 안전 helper가 아닌 raw infrastructure facade가 된다. + +선택하지 않는다. + +### C. capability-provider마다 즉시 leaf 분리 + +```text +cache-redis +rate-limit-redis +lock-redis +idempotency-redis +session-redis +``` + +물리 격리는 가장 명확하다. 그러나 현재 exact-19 registry를 즉시 바꾸고 동일 client/config/program +기반을 여러 module에 중복한다. semantic contract가 아직 구현으로 검증되지 않은 시점에 public +path를 고정하는 비용이 크다. + +R2 첫 단계에는 선택하지 않는다. 독립 release/security/dependency lifecycle이 생기면 다시 +평가한다. + +### D. 한 physical Redis leaf, capability별 package와 semantic port + +현재 registry를 유지하면서 실제 SDK와 공통 key/codec/program runtime을 한 곳에 둘 수 있다. +동시에 capability별 provider, failure policy, settings, test kit를 분리할 수 있다. + +선택한다. 단, “한 leaf”는 “한 connection”, “한 Redis deployment”, “한 fail-open policy”를 +뜻하지 않는다. + +### E. Redisson API를 중심으로 모든 기능 제공 + +Redisson은 lock, rate limiter, map cache 같은 고수준 primitive를 제공한다. 구현량은 줄지만 +provider-specific semantics와 watchdog/failover 가정이 application policy에 스며들기 쉽고, +Spring Data/Spring Session과 별도 client lifecycle이 중복될 수 있다. + +기본 선택으로 사용하지 않는다. 특정 product가 Redisson capability를 선택할 경우 동일 semantic +contract와 contract suite를 통과하는 별도 provider로 추가할 수 있다. + +### F. Redis Functions만 허용 + +Functions는 server에 versioned library를 배포하고 runtime 계정에서 `FCALL`만 허용하기 쉬워 +production least privilege에 유리하다. 그러나 일부 managed Redis의 provisioning 권한, version, +배포 lifecycle이 다르고 모든 primary에 선배포해야 한다. + +production 권장 profile로 열어두지만 portable R2의 유일한 모드로 강제하지 않는다. EVALSHA +compatibility profile과 명시적으로 구분한다. + +## 7. 목표 아키텍처 + +```mermaid +flowchart LR + WEB[adapter:inbound:web] -->|HTTP mapping| EDGE[shared edge rate-limit contract] + WEB --> APP[application-core use case] + APP --> CACHEPORT[semantic cache region port] + APP --> IDEMPORT[idempotency port] + APP --> LEASEPORT[lease/fencing port] + + CACHEPORT --> REDISCACHE[Redis cache provider] + IDEMPORT --> REDISIDEM[Redis idempotency provider] + IDEMPORT --> JPAIDEM[JPA idempotency provider] + LEASEPORT --> REDISLEASE[Redis lease provider] + LEASEPORT --> JDBCLOCK[JDBC lock provider] + EDGE --> REDISRATE[Redis rate-limit provider] + + BOOT[app-bootstrap] -. selects/binds/validates .-> REDISCACHE + BOOT -. selects/binds/validates .-> REDISIDEM + BOOT -. selects/binds/validates .-> REDISLEASE + BOOT -. selects/binds/validates .-> REDISRATE + BOOT -. composes session mode .-> SESSION[Spring Session Redis] + + REDISCACHE --> CACHEDEP[(cache deployment)] + REDISRATE --> COORDDEP[(coordination deployment)] + REDISIDEM --> COORDDEP + REDISLEASE --> COORDDEP + SESSION --> SESSIONDEP[(session deployment)] +``` + +핵심 방향은 다음과 같다. + +```text +business/domain policy + -> semantic application port + -> Redis capability provider + -> internal key/codec/program/client runtime + -> role-bound Redis deployment +``` + +`adapter:inbound:web`와 `adapter:outbound:cache-redis`는 서로 직접 의존하지 않는다. +`shared-contract`가 transport-edge rate-limit value contract를 소유하고 `app-bootstrap`이 두 +adapter를 조립한다. + +## 8. 모듈과 계층 소유권 + +| 소유 leaf | 포함 | 금지 | +| --- | --- | --- | +| `domain-core` | 실제 domain invariant와 value | Redis/cache/session/HTTP quota | +| `application-core` | typed cache region base port, cache policy, idempotency v2, lease/fencing port | Redis key/SDK/Lua/Spring | +| `shared-contract` | edge rate-limit request/decision, provider-neutral capability descriptor | Servlet, Redis topology/key/program detail, business quota | +| `adapter:inbound:web` | route/principal/IP/policy 해석, HTTP header/error mapping, session security behavior | Redis command, local business policy | +| `adapter:outbound:cache-redis` | client, key, codec, program, cache/rate/idempotency/lease/session storage provider | controller/use case/business rule | +| `adapter:outbound:persistence-jpa` | JPA idempotency/JDBC lock provider | Redis provider fallback | +| `app-bootstrap` | provider selection, role binding, conditional composition, startup guarantee validation | use-case logic | +| `sample-portfolio` | 실제 사용 예와 contract fixture | production leaf의 역의존 | + +현재 registry상 Redis leaf는 이미 `application-core`, `domain-core`, `shared-contract`, +`adapter-outbound-support`에 의존할 수 있다. 실제 구현 시 필요한 production edge만 Gradle에 +추가하고 domain dependency가 불필요하면 추가하지 않는다. + +### 8.1 Redis leaf package + +초기 package 구조는 다음과 같다. + +```text +dev.caskeleton.adapter.outbound.redis + runtime/ + connection/ + topology/ + capability/ + health/ + key/ + codec/ + program/ + cache/ + ratelimit/ + coordination/ + lease/ + fencing/ + idempotency/ + session/ + observability/ + config/ +``` + +물리 path가 `cache-redis`여도 새 code의 package root는 기술 책임을 정직하게 드러내는 +`...outbound.redis`를 사용한다. 기존 `...outbound.cache` package는 migration facade로 유지한 뒤 +제거한다. package 변경은 public-path snapshot과 migration note를 동반한다. + +### 8.2 leaf split trigger + +다음 중 하나가 성립하면 별도 registry migration으로 split한다. + +- Spring Session dependency를 cache/rate/lock consumer classpath에서 제거해야 한다. +- capability별 release cadence가 달라진다. +- 별도 security review와 artifact ownership이 필요하다. +- Redis Streams inbound consumer처럼 adapter direction이 바뀐다. +- client SDK가 달라진다. +- package-level ArchUnit만으로 dependency leakage를 막기 어렵다. +- module build/test 시간이 독립 lifecycle을 방해한다. + +단순 class 수 증가는 split 근거가 아니다. + +## 9. Capability readiness와 descriptor + +Redis라는 기술 전체에 하나의 readiness를 붙이지 않는다. + +| Level | 의미 | Redis 예 | +| --- | --- | --- | +| R0 | contract/seam | 현재 `RedisClient` | +| R1 | local service | standalone cache, local-only evidence | +| R2 | production baseline | real provider, security/failure/health/real-service test | +| R3 | scale/HA proven | failover/Cluster/rolling/capacity evidence | + +descriptor는 두 층으로 나눈다. `shared-contract`는 bootstrap이 모든 provider에 공통으로 사용하는 +provider-neutral descriptor만 소유한다. + +```java +public record CapabilityDescriptor( + String capabilityId, + String providerId, + CapabilityReadiness readiness, + Set guarantees, + Set nonGuarantees, + FailureMode failureMode, + ReadinessImpact readinessImpact, + boolean multiInstanceCapable, + String implementationVersion) {} +``` + +Redis leaf는 provider-specific 진단을 별도 타입으로 소유한다. + +```java +public record RedisProviderDescriptor( + String capabilityId, + RedisRole role, + RedisTopology topology, + String deploymentId, + String keySchemaVersion, + String hashKeyVersion, + String codecSchemaVersion, + String programSetVersion, + String minimumRedisVersion) {} +``` + +bootstrap의 provider selection은 `CapabilityDescriptor`만 사용한다. Redis-specific startup +validation과 bounded health detail만 `RedisProviderDescriptor`를 소비한다. 두 descriptor 모두 +SDK object나 credential/endpoint를 포함하지 않는다. + +예시: + +```text +capability=cache.worklog-summary +provider=redis +implementationVersion=redis-cache-v2 +readiness=R2 +role=cache +guarantees=[BOUNDED_TTL, EXPLICIT_DEGRADED_RESULT] +nonGuarantees=[READ_YOUR_WRITES, ATOMIC_DB_CACHE_WRITE] +failureMode=FAIL_OPEN_TO_SOURCE +``` + +다음과 같은 descriptor는 startup에서 거절한다. + +```text +capability=session +role=cache +failureMode=FAIL_OPEN +``` + +## 10. Redis role과 deployment isolation + +### 10.1 최소 role + +| Role | 데이터 | 기본 eviction | durability/read | 기본 장애 의미 | +| --- | --- | --- | --- | --- | +| `cache` | 재생성 가능한 positive/negative/stale entry와 `CACHE_REFRESH_SOFT_LEASE` | `allkeys-lfu` 또는 검증된 `allkeys-lru` | persistence optional, replica stale read optional | policy별 source fallback | +| `coordination` | strict quota, idempotency, lease/fence | `noeviction` | primary read, declared persistence/HA | fail closed/indeterminate | +| `session` | 인증 session과 optional index | `noeviction` | primary read, HA/persistence | re-auth 또는 fail closed | + +높은 rate-limit volume이나 Streams workload가 noisy-neighbor가 되면 다음 role을 추가로 분리한다. + +```text +rate-limit +stream +``` + +이는 key prefix나 Redis database number가 아니라 별도 managed database/cluster/instance를 뜻한다. + +### 10.2 왜 prefix와 DB number로 충분하지 않은가 + +`maxmemory-policy`, CPU, event loop, persistence fork, replication buffer, failover, connection +limit은 instance/deployment 단위다. cache key가 eviction을 유발하면 같은 deployment의 session과 +idempotency key도 정책의 영향을 받는다. + +Redis Cluster는 database 0만 사용한다. standalone에서 DB 1, DB 2로 나누어도 memory와 failure +domain은 같다. 따라서 logical database는 namespace일 뿐 guarantee isolation이 아니다. + +`CACHE_REFRESH_SOFT_LEASE`는 cache miss load를 줄이는 용도이고 eviction/loss/duplicate owner를 +허용한다. 일반 `EFFICIENCY_LEASE`, idempotency, fencing, strict quota는 coordination role만 +사용한다. 이 한정된 soft lease 예외를 generic lock binding으로 확대하지 않는다. + +### 10.3 binding model + +capability는 deployment endpoint를 직접 알지 못하고 role binding을 사용한다. + +```text +cache region -> cache role -> cache-main deployment +strict rate -> coordination role -> coord-main deployment +idempotency -> coordination role -> coord-main deployment +session -> session role -> session-main deployment +``` + +role마다 connection factory와 client resources를 분리한다. 하나의 global +`RedisConnectionFactory @Primary`를 사용하지 않는다. + +### 10.4 incompatible co-location validation + +동일 physical deployment ID에 다음 조합이 bind되면 production startup을 거절한다. + +- evictable cache + session; +- evictable cache + idempotency; +- evictable cache + fenced coordination; +- replica-read cache + primary-only correctness capability; +- mutually incompatible persistence/eviction attestation. + +local profile은 명시적 `allow-unsafe-colocation=true`로만 한 container를 공유할 수 있으며 +readiness는 R1로 강등된다. + +### 10.5 deployment policy ownership + +application은 `CONFIG SET`을 실행하지 않는다. maxmemory, eviction, AOF/RDB, replica, +Sentinel/Cluster, TLS, backup은 IaC/managed service 정책이 소유한다. + +runtime은 가능한 경우 read-only introspection으로 effective policy를 확인한다. managed service가 +`CONFIG GET`을 막으면 signed/operator attestation과 external conformance job을 사용한다. 확인할 수 +없다는 이유로 원하는 guarantee가 존재한다고 추정하지 않는다. + +## 11. Key model + +### 11.1 canonical shape + +모든 key는 중앙 `RedisKeyBuilder`로만 만든다. + +```text +ca:::::hv:kv:{}:: +``` + +예시: + +```text +ca:worklog-api:prod:cache:worklog-summary:hv1:kv2:{a8f3}:6eab...:entry +ca:worklog-api:prod:rate:login:hv1:kv1:{31d0}:98bd...:bucket +ca:worklog-api:prod:idem:create-worklog:hv2:kv2:{bf91}:9aa1...:record +ca:worklog-api:prod:lease:daily-export:hv1:kv1:{04cf}:2d50...:owner +``` + +`app`, `env`, capability와 region은 validated bounded slug다. tenant, principal, token, +session ID, client idempotency key, resource path는 raw로 넣지 않는다. + +### 11.2 digest + +식별자 종류에 따라 다음을 선택한다. + +- 이미 random opaque ID이고 노출 위험이 낮음: bounded SHA-256 digest; +- 사용자/tenant/email/IP처럼 dictionary attack 가능한 값: versioned HMAC-SHA-256; +- composite scope: length-prefixed canonical encoding 후 HMAC; +- rate-limit IP: trusted resolver가 normalized binary address를 만들고 HMAC. + +단순 문자열 delimiter join은 ambiguity가 있으므로 금지한다. + +```text +len(tenant) || tenant || len(principal) || principal || len(operation) || operation +``` + +key HMAC secret은 payload encryption key와 분리한다. canonical key의 +`hv` segment가 HMAC key version을 고정한다. rotation은 bounded +dual-read/dual-delete 또는 cold-cutover 정책을 명시하고, old `hv` key가 TTL/maintenance로 +drain된 뒤 ACL pattern을 제거한다. + +#### HMAC material과 rotation + +`RedisKeyDigestMaterialProvider`는 Redis leaf 소유 SPI이고 app-bootstrap이 generic secret +provider를 bridge한다. + +```java +public interface RedisKeyDigestMaterialProvider { + RedisKeyDigestMaterialResolution resolve( + KeyDigestProfileId profile, HashKeyVersion version, SecretReference reference); + RotationSubscription subscribe( + KeyDigestProfileId profile, RedisKeyDigestRotationListener listener); +} + +public record VersionedRedisKeyDigestMaterial( + KeyDigestAlgorithm algorithm, + HashKeyVersion version, + Instant expiresAt, + DestroyableSecret keyBytes) {} +``` + +HMAC profile은 algorithm, one write version, bounded readable versions, version별 secret reference, +rotation mode를 모두 가져야 한다. resolve는 credential SPI와 같이 unavailable/expired/ +permission/invalid를 구분하고 secret byte를 log/metric/descriptor에 넣지 않는다. Redis leaf가 +`adapter:outbound:identifier` sibling에 의존하지 않는다. + +rotation mode: + +- `dual-read-delete`: cache처럼 재생성 가능한 data만 허용. write는 새 `hv`, read는 newest-first + bounded probe, old hit는 metric 후 new key로 refresh 가능, invalidate는 모든 readable `hv`를 + bounded delete한다. 두 version의 key/slot을 atomic하다고 표현하지 않는다. +- `cold-cutover`: idempotency, lease/fence, strict rate처럼 두 namespace의 동시 owner/state가 + 위험한 capability. mutation admission을 닫고 holder/lease/state TTL을 drain/reconcile한 뒤 + write/read version을 한 번에 전환한다. +- rate policy가 무중단 rotation을 요구하면 old/new limiter를 모두 평가해 어느 하나 deny면 + deny하는 별도 conservative overlap revision을 사용한다. token/counter를 두 key 사이 atomic + migration했다고 주장하지 않는다. +- `fixed`: random opaque ID의 unkeyed digest처럼 secret rotation 축이 없는 profile. + +startup은 write version material 존재/미만료, readable version 최대 개수, algorithm 일치, +capability에 허용된 rotation mode, ACL prefix/version을 검증한다. old material을 제거하기 전 +key TTL upper bound, maintenance scan evidence, active owner/session 없음 또는 explicit cold +cutover evidence가 필요하다. + +profile 정의만으로 secret을 resolve하거나 watcher를 시작하지 않는다. active capability가 +profile을 참조할 때만 해당 version material/subscription을 만든다. + +### 11.3 hash tag + +`{slotTag}`는 같은 atomic operation에 필요한 최소 key group만 co-locate한다. + +- idempotency record와 its operation marker; +- sliding counter의 current/previous bucket; +- lease owner와 fencing counter; +- exact rate decision dedup record. + +tenant 전체를 hash tag로 쓰면 한 tenant의 모든 traffic이 한 slot/hot shard로 몰리므로 금지한다. +slot tag는 resource/policy digest의 bounded prefix다. + +### 11.4 version + +세 version을 분리한다. + +```text +key schema version +payload schema version +policy revision +``` + +key version은 physical layout/namespace를 바꾼다. payload version은 같은 key의 decode +compatibility를 바꾼다. policy revision은 rate/cache TTL 등 state interpretation을 바꾼다. + +정책이 바뀌었는데 기존 counter/token state를 새 의미로 재사용하지 않는다. rate-limit key에는 +policy revision을 포함한다. + +### 11.5 bounds + +key builder는 다음을 검증한다. + +- 전체 UTF-8 byte length; +- 각 slug length와 allowed character; +- digest algorithm/version; +- hash tag 정확히 하나; +- `{`, `}`가 user input에서 유입되지 않음; +- capability별 kind allowlist. + +invalid key input은 backend outage가 아니며 fail-open하지 않는다. + +### 11.6 mass invalidation + +regular request에서 pattern delete를 하지 않는다. + +선택지는: + +1. key schema/version bump; +2. region generation ID 교체; +3. known-key bounded batch invalidation; +4. operator maintenance의 rate-limited `SCAN` + `UNLINK`. + +generation key가 evict되어도 `0`으로 되돌아가 old namespace를 부활시키면 안 된다. missing이면 +새 random 128-bit generation을 `SET NX`로 초기화하고 loser는 winner 값을 읽는다. old entry는 +orphan이지만 다시 visible해지지 않는다. + +## 12. Payload와 serialization + +### 12.1 raw value 원칙 + +Redis runtime의 기본 value type은 `byte[]`다. application object를 reflection으로 자동 +serialize하지 않는다. JDK native serialization과 unrestricted polymorphic/default typing은 +금지한다. + +application semantic port는 typed value를 사용하지만 adapter binding은 명시적 codec을 등록한다. +`CacheCodec`은 `adapter:outbound:cache-redis`의 provider SPI다. application use case는 이 타입을 +보거나 호출하지 않는다. + +```java +public interface CacheCodec { + String schemaId(); + int writeVersion(); + byte[] encode(T value); + DecodeResult decode(int storedVersion, byte[] payload); +} +``` + +`CacheCodec`에는 Jackson, JSON node, Redis serializer 타입이 없다. 구체 codec과 +application-value mapping은 Redis leaf의 product-specific binding class가 소유한다. 현재 +exact-19 skeleton에는 별도 application-adapter leaf가 없으므로 존재하지 않는 “mapping module”을 +가정하지 않는다. + +### 12.2 cache envelope + +baseline envelope는 다음 field를 갖는다. + +```text +magic +envelopeVersion +codecId +payloadVersion +flags [negative, compressed] +sourceRevision? +writtenAtEpochMillis +softExpiresAtEpochMillis? +hardExpiresAtEpochMillis +payloadLength +payloadDigest +payload +``` + +Redis key TTL은 hard expiry 이후의 physical cleanup을 담당한다. envelope hard expiry는 client가 +stale/expired를 판정하고 clock/TTL drift를 관측하는 방어선이다. + +### 12.3 compatibility + +- writer는 한 version만 쓴다. +- reader는 현재 N과 migration window의 N-1을 읽는다. +- N-1 read는 N으로 opportunistic rewrite할 수 있다. +- unknown future version은 `SCHEMA_MISMATCH`이며 miss와 별도 metric을 남긴다. +- decoder exception, invalid length, digest mismatch는 `CORRUPT`다. +- corrupt entry는 bounded owner-safe quarantine/evict 후 policy에 따라 source를 조회한다. +- programming bug를 Redis unavailable로 분류하지 않는다. + +rolling deploy에서 old reader가 new payload를 읽을 수 없으면 writer 전환 전에 dual-readable +codec을 배포한다. + +### 12.4 size와 compression + +region마다 다음 bound가 필수다. + +- maximum encoded bytes; +- maximum decoded bytes; +- maximum collection elements; +- maximum compression ratio; +- encode/decode deadline. + +oversize는 cache write를 `REJECTED_TOO_LARGE`로 만들 수 있으나 source result 자체를 실패시키지 +않는다. session/idempotency response oversize는 해당 capability 계약에 따라 fail closed한다. + +compression은 threshold 이상에서만 opt-in한다. decompression bomb를 막기 위해 decoded size와 +ratio를 먼저 제한한다. secret과 attacker-controlled value를 같은 compressed context에 섞지 +않는다. + +### 12.5 session/idempotency codec + +cache codec을 session과 idempotency에 그대로 재사용하지 않는다. + +- session: allowlisted security/session attribute schema와 rolling compatibility; +- idempotency: request fingerprint metadata와 bounded response codec; +- rate/lease: fixed primitive schema, arbitrary object serialization 없음. + +## 13. Atomic program registry + +### 13.1 목적 + +Redis command 하나는 원자적으로 실행되지만 다음 client flow는 원자적이지 않다. + +```text +GET -> decide -> SET +INCR -> EXPIRE +GET owner -> DEL +GET owner -> PEXPIRE +find record -> claim +``` + +다른 client가 명령 사이에 끼어들 수 있고 첫 command 성공 뒤 connection이 끊길 수 있다. +`AtomicRedisOperations`는 자주 필요한 안전 recipe를 adapter 내부에 제공한다. + +### 13.2 application에 노출하지 않는 catalog + +baseline: + +```text +set-if-absent-with-ttl +compare-and-delete +compare-and-expire +compare-and-set-with-ttl +increment-with-initial-ttl +region-generation-init +region-generation-bump +cache-refresh-claim +cache-refresh-release +rate-fixed-window +rate-sliding-counter +rate-token-bucket +idempotency-claim +idempotency-start +idempotency-renew +idempotency-complete +idempotency-fail +idempotency-release +idempotency-inspect +idempotency-reconcile-committed +idempotency-reopen-no-effect +lease-acquire +lease-inspect +lease-renew +lease-release +session-create +session-inspect +session-save-if-live +session-touch-if-live +session-tombstone-and-delete +session-rotate +``` + +advanced: + +```text +rate-sliding-log +rate-gcra +fenced-counter-provision +fenced-lease-acquire +fenced-lease-inspect +fenced-lease-renew +fenced-lease-release +bounded-semaphore-acquire +bounded-semaphore-release +stream-publish-with-dedup +``` + +catalog가 존재한다고 모든 application에서 사용해야 하는 것은 아니다. 사용하지 않는 program은 +connection이나 state를 만들지 않는다. + +### 13.3 descriptor + +각 program은 code와 함께 다음 descriptor를 가진다. + +```java +public record RedisProgramDescriptor( + String name, + int semanticVersion, + String libraryName, + String registeredFunctionName, + String sourceSha256, + int keyCount, + ClusterSlotRule clusterSlotRule, + String argumentSchema, + String resultSchema, + ComplexityBound complexity, + StateGrowthBound stateGrowth, + String minimumRedisVersion, + RetrySafety retrySafety, + TimeoutCertainty timeoutCertainty, + String metricOperation) {} +``` + +필수 문서: + +- unsafe multi-command recipe; +- program이 보장하는 atomicity scope; +- 보장하지 않는 replication/durability; +- Redis server clock/client clock 사용 여부; +- maximum keys/arguments/value bytes/iterations; +- wrong type와 malformed state 처리; +- first write 전 validation; +- Cluster same-slot rule; +- timeout 이후 reconciliation 방법; +- backward/forward result compatibility. + +### 13.4 bounded execution + +Lua/Function은 실행 중 Redis의 다른 작업을 막는다. 따라서: + +- O(1) 또는 명시적 작은 N; +- unbounded loop 금지; +- `KEYS`, dynamic `SCAN`, large `SMEMBERS/HGETALL/ZRANGE` 금지; +- caller가 collection bound를 우회하지 못하도록 server-side 검증; +- 모든 key를 `KEYS[]`로 전달; +- programmatically generated key 접근 금지; +- first mutation 전에 type, count, TTL, numeric range 검증; +- integer millisecond와 bounded fixed-point 사용; +- runtime source concatenation 금지; +- slow-program threshold와 CI execution budget 설정. + +Redis transaction에는 rollback이 없고 script도 write 후 runtime error가 나면 partial effect에 대한 +주의가 필요하다. program은 가능한 모든 검증을 첫 write 전에 끝낸다. + +### 13.5 deployment mode + +두 mode를 지원하되 자동 fallback하지 않는다. + +#### `functions-provisioned` + +- Redis Function library를 별도 provisioning job이 모든 primary에 선배포; +- `libraryName`과 `registeredFunctionName` 모두 semantic major/version을 포함; +- 예: `ca_rate_v2` library와 `ca_rate_token_bucket_v2` registered function; +- library name/version/digest 검증; +- application runtime 계정은 `FCALL`만 허용; +- v1/v2는 서로 다른 library와 registered function name으로 동시에 존재; +- v2 unique name을 모든 primary에 load -> replica propagation/all-node digest 확인 -> + application이 v2 registered name으로 전환 -> v1 caller drain 확인 -> v1 library delete; +- failover/reshard 후 promoted/new primary의 library 확인; +- startup mismatch는 required capability를 fail closed. + +production least-privilege 권장 mode다. + +동일 registered name을 유지해야 하면 blue/green 동시 존재를 주장하지 않는다. +`FUNCTION LOAD REPLACE`는 whole-library atomic replacement이므로 mixed-node rollout과 old caller +compatibility를 별도 절차로 다룬다. + +#### `evalsha-managed` + +- parameterized classpath Lua source와 SHA를 build artifact에 고정; +- `EVALSHA` 사용; +- `NOSCRIPT`일 때 fixed source만 `SCRIPT LOAD` 후 한 번 재시도; +- Cluster의 모든 target primary에서 lazy/eager load; +- runtime account가 `SCRIPT LOAD` 권한을 갖는 security trade-off 기록; +- pipeline 안에서 `NOSCRIPT` recovery를 기대하지 않음; +- source/checksum mismatch면 startup 실패. + +managed service가 Functions를 지원하지 않을 때 쓰는 compatibility mode다. + +Spring Data Redis 기본 script executor의 `EVALSHA -> EVAL` fallback에 의존하지 않는다. exact +`SCRIPT LOAD -> EVALSHA` protocol과 source allowlist를 유지하기 위해 +`VersionedRedisProgramExecutor`가 dedicated/native connection callback을 소유한다. command trace +integration test가 dynamic `EVAL`이나 다른 source가 전송되지 않음을 검증한다. + +### 13.6 program result + +program은 ambiguous `0/1/null` 대신 versioned numeric tuple을 반환한다. + +```text +[resultSchemaVersion, statusCode, serverNowMillis, primaryValue, auxiliaryValue...] +``` + +adapter가 status를 typed outcome으로 변환한다. unknown status/schema는 programming/ +compatibility error이며 fail-open miss가 아니다. + +### 13.7 mutation certainty + +모든 mutation outcome은 최소 다음을 구분한다. + +```text +APPLIED +NOT_APPLIED +CONFLICT +REJECTED +UNAVAILABLE_BEFORE_SEND +OVERLOADED_BEFORE_SEND +INDETERMINATE +``` + +socket timeout은 server가 command를 실행하지 않았다는 증거가 아니다. request가 server에 도달하고 +response만 유실될 수 있다. `INDETERMINATE`는 operation token으로 inspect/reconcile하거나 +capability-specific safe behavior로 전환한다. + +### 13.8 normative `program-set.json` + +program 이름 목록과 prose만으로 구현 호환성을 주장하지 않는다. 다음 machine-readable manifest가 +program contract의 SSOT다. + +```json +{ + "programSet": "ca-redis-programs-v1", + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 1, + "programs": [ + { + "id": "compare-and-delete-v1", + "libraryName": "ca_primitive_v1", + "registeredFunctionName": "ca_compare_and_delete_v1", + "scriptResource": "redis/scripts/compare-and-delete-v1.lua", + "sha256": "", + "keys": [ + {"index": 1, "name": "ownerKey", "sameSlotGroup": "resource"} + ], + "arguments": [ + {"index": 1, "name": "expectedOwner", "type": "opaque-bytes", "maxBytes": 128} + ], + "state": {"type": "string", "maximumBytes": 128, "ttl": "existing"}, + "validateBeforeFirstWrite": ["key-type", "owner-length"], + "statuses": ["DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE"], + "complexity": "O(1)", + "stateGrowth": "none", + "clock": "none", + "retrySafety": "inspect-or-repeat-desired-absent", + "timeoutCertainty": "indeterminate", + "aclCommands": ["GET", "DEL"] + } + ] +} +``` + +manifest schema 자체를 JSON Schema와 Java parser test로 고정한다. ``는 설계 +placeholder가 아니라 build가 resource bytes에서 생성하고 release artifact에서 non-empty exact +digest로 치환해야 하는 field다. + +각 entry는 반드시: + +- exact `KEYS[index]`; +- exact ordered `ARGV[index]`, type, byte/numeric bound; +- state data type/field/version/TTL; +- first-write 이전 validation; +- complete status code enum; +- complexity와 per-call removal/iteration bound; +- clock source; +- retry/timeout certainty; +- minimum Redis version; +- exact ACL command allowlist; +- golden input/state/output vectors + +를 갖는다. manifest, Java typed facade, Lua/Function source 중 하나라도 drift하면 build가 실패한다. + +### 13.9 baseline helper normative matrix + +아래는 baseline manifest의 필수 최소 shape다. `K1`, `A1`은 exact positional index다. + +| Program | `KEYS` | Ordered `ARGV` | State / TTL | First-write boundary | Status | +| --- | --- | --- | --- | --- | --- | +| `set-if-absent-with-ttl-v1` | K1 target | A1 value bytes, A2 TTL ms, A3 operation ID | string / A2 | type, value bytes, TTL, op ID | `SET`, `EXISTS`, `WRONG_TYPE`, `INVALID` | +| `compare-and-delete-v1` | K1 owner | A1 expected owner | string / existing | type, owner bytes | `DELETED`, `ABSENT`, `NOT_OWNER`, `WRONG_TYPE` | +| `compare-and-expire-v1` | K1 owner | A1 expected owner, A2 new TTL ms | string / A2 on match | type, owner, TTL | `RENEWED`, `ABSENT`, `NOT_OWNER`, `WRONG_TYPE` | +| `compare-and-set-with-ttl-v1` | K1 entry | A1 expected revision/digest, A2 new envelope, A3 TTL ms | versioned string / A3 | type, envelope size/version, TTL | `STORED`, `ABSENT`, `REVISION_CONFLICT`, `INVALID` | +| `increment-with-initial-ttl-v1` | K1 counter | A1 positive delta, A2 max, A3 TTL ms | signed integer string / initialize once | type, range, overflow, TTL | `INCREMENTED`, `LIMIT_EXCEEDED`, `OVERFLOW`, `WRONG_TYPE` | +| `region-generation-init-v1` | K1 generation | A1 random generation ID | opaque string / no TTL | type, ID length | `INITIALIZED`, `EXISTING`, `WRONG_TYPE` | +| `region-generation-bump-v1` | K1 generation | A1 new random generation, A2 operation ID | opaque string / no TTL | type, generation/op length | `BUMPED`, `ALREADY_APPLIED`, `WRONG_TYPE` | +| `cache-refresh-claim-v1` | K1 soft lease | A1 owner, A2 lease TTL ms, A3 operation ID | owner envelope / A2 | type, owner/op, TTL | `ACQUIRED`, `CONTENDED`, `ALREADY_OWNED`, `INVALID` | +| `cache-refresh-release-v1` | K1 soft lease | A1 owner | owner envelope / existing | type, owner | `RELEASED`, `ABSENT`, `NOT_OWNER` | + +공통 bound: + +- key count는 표와 정확히 일치; +- value/envelope maximum은 region/provider manifest가 numeric value로 resolve; +- TTL `1..maxTtlMillis`; +- operation/owner ID는 fixed maximum bytes; +- `increment`는 signed 64-bit hard bound와 configured lower hard-fail threshold; +- generation/coordination non-ephemeral key는 role policy가 허용할 때만 TTL 없음. + +### 13.10 capability state programs + +| Program | `KEYS` | Ordered `ARGV` | State / TTL | First-write boundary | Status | +| --- | --- | --- | --- | --- | --- | +| `rate-fixed-window-v1` | K1 policy-subject | A1 policy revision, A2 limit, A3 cost, A4 window ms, A5 evaluation ID? | versioned hash / window remainder + grace | type/schema, ranges, clock regression, dedup | `ALLOWED`, `DENIED`, `DEDUP_REPLAY`, `CLOCK_UNSAFE`, `INVALID` | +| `rate-sliding-counter-v1` | K1 policy-subject | A1 revision, A2 limit, A3 cost, A4 window ms, A5 evaluation ID? | versioned hash / 2 windows + grace | same | same | +| `rate-token-bucket-v1` | K1 policy-subject | A1 revision, A2 capacity scaled, A3 refill scaled, A4 period ms, A5 cost scaled, A6 evaluation ID? | versioned hash / full-refill horizon + grace | type/schema, scale/ranges/overflow, clock | same | +| `idempotency-claim-v1` | K1 record | A1 fingerprint, A2 owner, A3 operation ID, A4 processing TTL ms, A5 replay TTL ms, A6 codec, A7 policy revision | versioned hash / state-dependent | complete request/state/type/size/TTL before owner write | `ACQUIRED`, `REPLAYED_ACQUIRE`, `COMPLETED_REPLAY`, `IN_PROGRESS`, `RECOVERY_REQUIRED`, `FINGERPRINT_MISMATCH`, `TAKEN_OVER_CLAIMED`, `OWNER_OPERATION_CONFLICT`, `INVALID` | +| `idempotency-start-v1` | K1 record | A1 owner, A2 attempt, A3 operation ID | `CLAIMED -> EXECUTING` / existing processing TTL | schema/state/owner/attempt/op before state write | `STARTED`, `ALREADY_STARTED_SAME_OPERATION`, `ABSENT`, `NOT_OWNER`, `NOT_CLAIMED`, `OPERATION_CONFLICT`, `INVALID` | +| `idempotency-renew-v1` | K1 record | A1 owner, A2 attempt, A3 operation ID, A4 processing TTL ms | claimed/executing hash / renewed processing TTL | state/schema/owner/attempt/op/TTL | `RENEWED`, `ALREADY_RENEWED_SAME_OPERATION`, `ABSENT`, `NOT_OWNER`, `NOT_IN_PROGRESS`, `OPERATION_CONFLICT`, `INVALID` | +| `idempotency-complete-v1` | K1 record | A1 owner, A2 attempt, A3 operation ID, A4 codec/version, A5 response digest, A6 response bytes/ref, A7 replay TTL ms | completed hash / replay TTL | all response/state/owner/attempt/op bounds before state write | `COMPLETED`, `ALREADY_COMPLETED_SAME_RESULT`, `RESPONSE_CONFLICT`, `ABSENT`, `NOT_OWNER`, `NOT_IN_PROGRESS`, `OPERATION_CONFLICT`, `INVALID` | +| `idempotency-fail-v1` | K1 record | A1 owner, A2 attempt, A3 operation ID, A4 disposition, A5 retry/audit TTL ms | failed/abandoned hash / A5 | state/owner/attempt/op/disposition/TTL | `MARKED_RETRYABLE`, `MARKED_ABANDONED`, `ALREADY_MARKED_SAME_OPERATION`, `ABSENT`, `NOT_OWNER`, `NOT_IN_PROGRESS`, `OPERATION_CONFLICT`, `INVALID` | +| `idempotency-release-v1` | K1 record | A1 owner, A2 attempt, A3 operation ID | claimed hash / delete or bounded audit marker | schema/state/owner/attempt/op before delete | `RELEASED_BEFORE_EXECUTION`, `ALREADY_RELEASED_SAME_OPERATION`, `ABSENT`, `NOT_OWNER`, `EXECUTION_ALREADY_STARTED`, `OPERATION_CONFLICT`, `INVALID` | +| `idempotency-inspect-v1` | K1 record | A1 fingerprint, A2 owner, A3 operation ID | versioned hash / read-only with PTTL | schema/fingerprint/owner/op/state | `ABSENT`, `CLAIMED_SAME_OPERATION`, `EXECUTING_SAME_OPERATION`, `COMPLETED_REPLAY`, `IN_PROGRESS_OTHER`, `FAILED_RETRYABLE`, `ABANDONED`, `FINGERPRINT_MISMATCH`, `OPERATION_CONFLICT`, `INVALID` | +| `idempotency-reconcile-committed-v1` | K1 record | A1 expected attempt, A2 expected state revision, A3 evidence digest, A4 audit operation ID, A5 codec/version, A6 response digest, A7 response bytes/ref, A8 replay TTL ms | abandoned -> completed / replay TTL | schema/state/attempt/revision/evidence/response/TTL before state write | `RECONCILED_COMPLETED`, `ALREADY_RECONCILED_SAME_OPERATION`, `EVIDENCE_CONFLICT`, `STATE_CONFLICT`, `ABSENT`, `INVALID` | +| `idempotency-reopen-no-effect-v1` | K1 record | A1 expected attempt, A2 expected state revision, A3 evidence digest, A4 audit operation ID, A5 new owner, A6 new claim operation ID, A7 processing TTL ms | abandoned -> claimed attempt+1 / processing TTL | schema/state/attempt/revision/evidence/new owner/op/TTL before state write | `REOPENED_CLAIMED`, `ALREADY_REOPENED_SAME_OPERATION`, `EVIDENCE_CONFLICT`, `STATE_CONFLICT`, `ABSENT`, `INVALID` | +| `lease-acquire-v1` | K1 owner | A1 owner, A2 lease TTL ms, A3 operation ID | owner/op envelope / A2 | type/owner/op/TTL | `ACQUIRED`, `REPLAYED_SAME_OPERATION`, `CONTENDED`, `OWNER_OPERATION_CONFLICT`, `INVALID` | +| `lease-inspect-v1` | K1 owner | A1 owner, A2 operation ID | owner/op envelope / read-only with PTTL | type/owner/op/live TTL | `OWNED`, `ABSENT`, `NOT_OWNER`, `OWNER_OPERATION_CONFLICT`, `INVALID` | +| `lease-renew-v1` | K1 owner | A1 owner, A2 new TTL ms | owner envelope / A2 | type/owner/TTL | `RENEWED`, `ABSENT`, `NOT_OWNER`, `INVALID` | +| `lease-release-v1` | K1 owner | A1 owner | owner envelope / existing | type/owner | `RELEASED`, `ABSENT`, `NOT_OWNER` | +| `session-create-v1` | K1 session, K2 tombstone (same slot) | A1 mutation operation ID, A2 revision, A3 serializer/version, A4 payload digest, A5 payload, A6 idle TTL ms, A7 absolute expiry epoch ms | versioned session with last-mutation op/digest + tombstone / `min(idle, absolute-now)` | both types, op/payload/schema/revision/TTL/absolute before create | `CREATED`, `ALREADY_CREATED_SAME_OPERATION`, `EXISTS_CONFLICT`, `TOMBSTONED`, `ABSOLUTE_EXPIRED`, `INVALID` | +| `session-inspect-v1` | K1 session, K2 tombstone (same slot) | A1 expected mutation operation ID, A2 expected payload digest?, A3 expected revision? | live session/tombstone / read-only with PTTL | both schemas, last mutation/revision/digest/tombstone | `LIVE_SAME_MUTATION`, `LIVE_OTHER`, `TOMBSTONED_SAME_OPERATION`, `TOMBSTONED_OTHER`, `ABSENT`, `ABSOLUTE_EXPIRED`, `INVALID` | +| `session-save-if-live-v1` | K1 session, K2 tombstone (same slot) | A1 mutation operation ID, A2 expected revision, A3 new revision, A4 serializer/version, A5 payload digest, A6 payload, A7 idle TTL ms, A8 absolute expiry epoch ms | versioned session with last-mutation op/digest / bounded live TTL | both types, op/tombstone/revisions/payload/TTL/absolute before write | `SAVED`, `ALREADY_SAVED_SAME_OPERATION`, `ABSENT`, `STALE_REVISION`, `MUTATION_CONFLICT`, `TOMBSTONED`, `ABSOLUTE_EXPIRED`, `INVALID` | +| `session-touch-if-live-v1` | K1 session, K2 tombstone (same slot) | A1 mutation operation ID, A2 expected revision, A3 idle TTL ms, A4 absolute expiry epoch ms, A5 minimum touch interval ms | session last-touch op + bounded live TTL | both types, op/tombstone/revision/times before TTL/write | `TOUCHED`, `ALREADY_TOUCHED_SAME_OPERATION`, `TOUCH_NOT_DUE`, `ABSENT`, `STALE_REVISION`, `MUTATION_CONFLICT`, `TOMBSTONED`, `ABSOLUTE_EXPIRED`, `INVALID` | +| `session-tombstone-and-delete-v1` | K1 session, K2 tombstone (same slot) | A1 expected revision, A2 operation ID, A3 tombstone TTL ms | tombstone revision/op + deleted session / A3 | both types, revisions/op/TTL before tombstone first write | `REVOKED_AND_DELETED`, `TOMBSTONED_ABSENT`, `ALREADY_REVOKED_SAME_OPERATION`, `STALE_REVISION`, `OPERATION_CONFLICT`, `INVALID` | +| `session-rotate-v1` | K1 old session, K2 old tombstone, K3 new session, K4 new tombstone (same slot or non-Cluster profile) | A1 expected old revision, A2 new revision, A3 operation ID, A4 idle TTL ms, A5 absolute expiry epoch ms, A6 old tombstone TTL ms | new live session + old tombstone/delete / bounded TTLs | all four types, revisions/op/new-key absence/TTL/absolute before first write | `ROTATED`, `ALREADY_ROTATED_SAME_OPERATION`, `OLD_ABSENT`, `STALE_REVISION`, `OLD_TOMBSTONED`, `NEW_ID_CONFLICT`, `ABSOLUTE_EXPIRED`, `INVALID` | +| `fenced-counter-provision-v1` | K1 fence | A1 resource epoch, A2 registration digest, A3 durable high watermark | no-TTL versioned fence envelope | schema, epoch/digest, high watermark/range before create | `PROVISIONED`, `ALREADY_SAME`, `REGISTRATION_CONFLICT`, `REGRESSION`, `INVALID` | +| `fenced-lease-acquire-v1` | K1 owner, K2 fence (same slot) | A1 expected resource epoch, A2 registration digest, A3 owner, A4 lease TTL ms, A5 operation ID, A6 hard-fail threshold | owner/op/epoch/counter envelope + no-TTL versioned fence envelope | both types/schema/expected epoch+digest, owner/op, range, regression/missing policy, TTL before increment | `ACQUIRED`, `REPLAYED_SAME_OPERATION`, `CONTENDED`, `OWNER_OPERATION_CONFLICT`, `FENCE_COUNTER_MISSING`, `EPOCH_MISMATCH`, `REGISTRATION_CONFLICT`, `FENCE_REGRESSION`, `FENCE_EXHAUSTED`, `INVALID` | +| `fenced-lease-inspect-v1` | K1 owner, K2 fence (same slot) | A1 expected resource epoch, A2 registration digest, A3 owner, A4 operation ID | owner/op/epoch/counter envelope + no-TTL versioned fence envelope / read-only | both schemas/expected epoch+digest, owner/op, stored fence <= counter, live PTTL | `OWNED`, `ABSENT`, `NOT_OWNER`, `OWNER_OPERATION_CONFLICT`, `FENCE_COUNTER_MISSING`, `EPOCH_MISMATCH`, `REGISTRATION_CONFLICT`, `FENCE_REGRESSION`, `INVALID` | +| `fenced-lease-renew-v1` | K1 owner, K2 fence (same slot) | A1 expected epoch, A2 registration digest, A3 owner, A4 expected counter, A5 new TTL ms | same fenced owner envelope / A5; fence envelope no TTL | both schemas/epoch/digest/owner/counter/TTL before expire | `RENEWED`, `ABSENT`, `NOT_OWNER`, `TOKEN_MISMATCH`, `EPOCH_MISMATCH`, `REGISTRATION_CONFLICT`, `FENCE_COUNTER_MISSING`, `FENCE_REGRESSION`, `INVALID` | +| `fenced-lease-release-v1` | K1 owner, K2 fence (same slot) | A1 expected epoch, A2 registration digest, A3 owner, A4 expected counter | deleted owner; fence envelope unchanged/no TTL | both schemas/epoch/digest/owner/counter before delete | `RELEASED`, `ABSENT`, `NOT_OWNER`, `TOKEN_MISMATCH`, `EPOCH_MISMATCH`, `REGISTRATION_CONFLICT`, `FENCE_COUNTER_MISSING`, `FENCE_REGRESSION`, `INVALID` | + +rate의 exact formula/boundary는 §20, idempotency state/result는 §24, fencing lifecycle은 §23이 +추가 normative source다. manifest는 그 section의 revision/digest를 참조한다. + +session program은 Redis `TIME`을 한 번 읽고 physical TTL을 +`min(idleTtl, absoluteExpiresAt-serverNow)`로 정한다. tombstone/revision check와 save/touch/delete는 +같은 invocation에서 이루어진다. Sentinel/standalone R2에서는 rotate 네 key를 한 primary에서 +원자 실행한다. Cluster session profile은 네 key가 stable opaque session-lineage hash tag로 같은 +slot임을 key builder/cookie format/`CLUSTER KEYSLOT` test가 증명할 때만 rotate guarantee를 +광고한다. 그렇지 않으면 Cluster session profile은 R2가 아니다. + +### 13.11 typed Java facade와 golden vectors + +caller는 generic `execute(name, keys, args)`를 사용하지 않는다. + +```java +CompareDeleteResult compareAndDelete(OwnerKey key, OwnerToken expected); +TokenBucketResult evaluateTokenBucket(TokenBucketCommand command); +IdempotencyCompleteResult complete(IdempotencyCompleteCommand command); +LeaseRenewResult renew(LeaseRenewCommand command); +``` + +각 facade의 sealed result enum은 manifest status와 1:1이다. unknown numeric status는 +compatibility failure다. + +각 program은 최소: + +- empty/absent state; +- normal apply; +- condition reject; +- wrong owner/fingerprint/revision; +- boundary numeric/TTL; +- wrong Redis type; +- repeat same operation; +- timeout-after-apply reconciliation; +- Cluster same-slot + +golden vector를 JSON fixture로 가진다. Functions와 EVALSHA mode가 같은 vector 결과를 내야 한다. + +## 14. Command, transaction, pipeline 의미 + +### 14.1 single-thread 오해 + +Redis가 명령을 직렬 실행하더라도 client-side workflow 전체가 직렬화되는 것은 아니다. + +```text +Client A: GET x +Client B: GET x +Client A: SET x 1 +Client B: SET x 1 +``` + +두 client 모두 동일 old value에서 판단할 수 있다. 단일 command, `WATCH` CAS, Lua/Function 중 +하나로 atomic boundary를 만들어야 한다. + +### 14.2 `MULTI/EXEC` + +- queued command를 순서대로 실행한다. +- transaction 안의 다른 client command interleaving을 막는다. +- runtime command error에 rollback이 없다. +- network timeout 뒤 `EXEC` 수행 여부는 indeterminate일 수 있다. +- cross-slot key는 Cluster에서 사용할 수 없다. + +`MULTI/EXEC`를 relational transaction으로 설명하지 않는다. + +### 14.3 `WATCH` + +`WATCH`는 optimistic CAS다. contention에서 abort/retry가 발생하고 connection affinity가 필요하다. +bounded low-contention update에는 사용할 수 있으나 rate limit, owner-safe release처럼 고빈도 +primitive의 기본 구현은 Function/Lua를 사용한다. + +### 14.4 pipeline + +pipeline은 network round-trip을 줄인다. atomicity나 rollback을 제공하지 않는다. 다른 client의 +command가 끼어들 수 있으며 partial response/timeout 처리도 필요하다. + +다음 용도로 제한한다. + +- independent bounded multi-get; +- independent bounded invalidation; +- metrics/maintenance의 bounded read; +- result별 성공/실패를 독립 처리할 수 있는 operation. + +### 14.5 expirable mutation + +다음은 금지한다. + +```text +SET key value +EXPIRE key ttl +``` + +첫 command 후 process가 죽으면 TTL 없는 key가 남는다. `SET ... PX`, field TTL이 필요한 +supported version command, 또는 atomic program을 사용한다. + +### 14.6 common primitive 제공 원칙 + +개발자가 매번 raw command의 race와 bound를 다시 발견하게 두지 않는다. Redis leaf 내부에 +`RedisPrimitiveCatalog`를 제공하되 application에 generic `RedisOperations`나 command string을 +노출하지 않는다. + +```text +StringValuePrimitives +CounterPrimitives +HashPrimitives +SetPrimitives +SortedSetPrimitives +ListPrimitives +BitmapPrimitives +HyperLogLogPrimitives +GeoPrimitives +``` + +각 primitive method는: + +- typed/versioned key만 받음; +- encoded/decoded byte bound; +- finite deadline과 command certainty; +- role allowlist; +- Cluster slot rule; +- TTL policy; +- collection result/count bound; +- retry classification; +- metric operation name + +을 descriptor에서 가져온다. raw `byte[] key`, arbitrary command, unbounded range, caller-provided +Lua source는 public API가 아니다. + +새 product 기능이 이 primitive를 원하면 application에 `LeaderboardPort`, +`UniqueVisitorEstimatePort`처럼 semantic port를 만들고 Redis leaf의 thin adapter가 내부 +primitive를 사용한다. core가 `ZADD`, `PFADD`를 직접 호출하지 않는다. + +### 14.7 data structure별 baseline과 함정 + +| Structure | 제공 baseline | 반드시 막는 함정 | +| --- | --- | --- | +| String | bounded `GET`, `MGET`, `SET PX`, `SET NX/XX PX`, conditional delete/set | `SET` 후 별도 `EXPIRE`, unbounded value, GET-판단-SET race | +| Counter | bounded signed `INCRBY`, saturating/read, initial-TTL atomic program | `INCR` 후 별도 `EXPIRE`, overflow, response-loss automatic retry/double charge | +| Hash | `HGET`, bounded `HMGET`, `HSET`, `HDEL`, bounded `HSCAN` | request path `HGETALL`, unbounded fields, whole-key TTL을 field TTL로 오해 | +| Set | `SISMEMBER`, `SADD`, `SREM`, `SCARD`, bounded `SSCAN` | unbounded `SMEMBERS`, attacker-controlled cardinality, exact set algebra on huge keys | +| Sorted set | `ZADD`, `ZREM`, `ZCOUNT`, rank/score range with explicit limit, bounded trim | unbounded `ZRANGE`, floating score precision 오해, trim의 `O(log N + M)` 비용 | +| List | bounded push/pop/trim, blocking pop은 전용 connection | durable queue/ack/reclaim로 오해, unbounded `LRANGE`, shared connection block | +| Bitmap | bounded `GETBIT`/`SETBIT`/`BITCOUNT`, fixed offset domain | attacker가 큰 offset으로 sparse allocation 유발, tenant bit leakage | +| HyperLogLog | `PFADD`/`PFCOUNT`를 approximate cardinality semantic port 뒤에서 사용 | exact count/billing/security decision에 사용, uncontrolled merge fan-in | +| Geo | bounded `GEOADD`/radius search with count/sort bound | raw precise location 보존·로그, unbounded radius/result, authorization 누락 | + +Redis 7.2 portable minimum에서는 hash field별 expiration을 baseline으로 가정하지 않는다. hash +field마다 독립 TTL이 필요하면: + +1. field를 독립 versioned key로 분리하거나; +2. envelope expiry + bounded lazy cleanup program을 사용하거나; +3. product minimum version을 별도 ADR/evidence로 높인다. + +새er command가 더 편리해도 compatibility matrix가 지원하기 전에는 program manifest의 portable +recipe를 유지한다. + +### 14.8 multi-step recipe의 atomic 승격 기준 + +다음 형태는 single-thread Redis에서도 client race가 있으므로 제공 primitive가 Function/Lua 또는 +검증된 `WATCH` CAS로 승격한다. + +| Unsafe recipe | Safe primitive | +| --- | --- | +| `GET owner` -> compare -> `DEL` | `compare-and-delete-v1` | +| `GET owner` -> compare -> `PEXPIRE` | `compare-and-expire-v1` | +| `INCR` -> first request면 `EXPIRE` | `increment-with-initial-ttl-v1` | +| `GET version` -> compare -> `SET PX` | `compare-and-set-with-ttl-v1` | +| `SCARD` -> limit check -> `SADD` | bounded set-admission program | +| `ZREMRANGEBYSCORE` -> `ZCOUNT` -> `ZADD` | rate/sliding-log program | +| `LLEN` -> capacity check -> `LPUSH` | bounded list-admission program | +| `HGET revision` -> conditional `HSET` | revision-CAS program | + +program을 쓴다고 무조건 안전한 것은 아니다. §13 manifest의 first-write validation, time/TTL, +same-slot, state-growth, result schema, timeout certainty를 모두 가져야 catalog에 등록된다. + +### 14.9 bulk와 collection API + +bulk operation은 `maximumKeys`, `maximumElements`, `maximumEncodedBytes`, total deadline을 +필수로 받거나 region descriptor에서 고정한다. + +- `MGET`/pipeline은 같은 snapshot이나 atomic read가 아니다. +- Cluster multi-key command는 same-slot일 때만 사용하고, 그 외에는 bounded per-node pipeline과 + partial result를 반환한다. +- bulk mutation의 일부 성공을 단일 boolean로 합치지 않는다. +- `SCAN` 결과는 duplicate/missing observation을 허용하는 maintenance cursor다. +- collection page token은 topology/schema revision을 포함하고 unlimited export API가 아니다. + +### 14.10 queue와 messaging 경계 + +List의 `LPUSH/BRPOP`만으로 ack, visibility timeout, reclaim, poison handling, durable replay가 +생기지 않는다. 단순 best-effort work handoff가 아니라면: + +- Redis Streams의 consumer group/PENDING/claim/ack/trim을 가진 별도 messaging semantic port; +- 또는 Kafka/JDBC queue/outbox + +를 선택한다. Stream도 duplicate delivery, pending-entry leak, trim data loss, consumer crash, +Cluster key placement를 별도 contract로 해결해야 하며 cache primitive catalog가 durable +messaging guarantee를 광고하지 않는다. + +### 14.11 Redis Stack/module capability + +Bloom/Cuckoo filter, Count-Min Sketch, Top-K, TimeSeries, JSON, Search/vector query는 plain Redis 7.2 +portable command가 아니다. skeleton baseline에 있는 것처럼 보이게 하지 않고 각각 opt-in +provider capability로 연다. + +| Capability | Semantic contract 예 | 핵심 non-guarantee/risk | +| --- | --- | --- | +| Probabilistic membership | `MightContainPort` | false positive, capacity/error-rate sizing, rebuild | +| Approximate frequency | `FrequencyEstimatePort` | exact billing/audit 불가, merge/error bound | +| Time series | `MetricSeriesPort` | retention/downsample/duplicate policy, observability backend 대체 아님 | +| JSON document | product-specific document port | aggregate consistency/JPA replacement 아님, schema/index migration | +| Search/vector | `SearchPort`/retrieval port | eventual index visibility, ranking drift, memory/index rebuild | + +activation은 exact module/server image, command/version compatibility, ACL, license, backup/restore, +Cluster/failover, memory amplification, index build/rolling migration test가 있는 capability card를 +요구한다. module이 없는 server에서 command probe 실패 시 plain data structure로 자동 +fallback하지 않는다. + +## 15. Cache application contract + +### 15.1 port 형태 + +application-core는 technical TTL/codec/topology를 모르는 최소 provider-neutral base contract를 +제공한다. + +```java +public interface CacheRegionPort { + CacheLookup lookup(K key); + CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata); + CacheRecordOutcome recordAbsent( + K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata); + CacheInvalidationOutcome invalidate(K key); +} +``` + +실제 use case는 semantic name을 갖는 interface를 정의한다. + +```java +public interface WorkLogSummaryCachePort + extends CacheRegionPort {} +``` + +위 이름은 forked product의 illustrative shape이며 현재 production template이나 +`sample-portfolio`에 추가하지 않는다. current registry에는 +`sample-portfolio -> adapter-outbound-cache-redis` edge가 없고 추가하지 않는다. + +실제 product에서는 `application-core`에 semantic subtype을 두고 Redis leaf가 thin binding을 +명시적으로 구현한다. + +```java +final class WorkLogSummaryRedisCacheAdapter implements WorkLogSummaryCachePort { + private final RedisCacheRegion delegate; + // 모든 method를 delegate하되 region ID/codec/effective policy는 constructor에서 freeze한다. +} +``` + +Redis leaf는 `application-core` dependency가 registry에서 허용되어 이 방향이 합법적이다. +generic `RedisCacheRegion` bean 하나가 subtype을 자동 구현한다고 가정하지 않는다. skeleton의 +실행 가능한 reference는 sample dependency가 아니라 Redis leaf test source의 test-only semantic +port/binding/codec fixture로 증명한다. + +### 15.2 lookup + +```text +Hit( + value, + freshness = FRESH | STALE, + sourceRevision?, + softExpiresAt?, + hardExpiresAt +) + +NegativeHit( + reason, + hardExpiresAt +) + +Miss( + reason = ABSENT | EXPIRED | INVALIDATED +) + +IncompatibleSchema( + category = FUTURE_VERSION | RETIRED_VERSION | UNKNOWN_ENVELOPE, + policy = FAIL_FAST | QUARANTINE_AND_RELOAD +) + +Unavailable( + category = UNAVAILABLE | OVERLOADED, + certainty +) +``` + +topology, OOM, connection 같은 provider detail은 Redis adapter metric/log에 남고 application +contract에는 노출하지 않는다. + +`SCHEMA_MISMATCH`, `CORRUPT`, `PROGRAMMING_ERROR`는 ordinary unavailable이나 miss로 반환하지 +않는다. future writer version은 default `FAIL_FAST` + readiness/compatibility alert이며 old reader가 +entry를 지우거나 source로 덮어쓰지 않는다. approved retired-version migration처럼 region의 +compiled policy가 `QUARANTINE_AND_RELOAD`를 명시한 경우에만 bounded quarantine/invalidate 후 +source load를 허용하고, 결과/metric은 계속 `IncompatibleSchema` 경로로 기록한다. corrupt와 +programming error는 typed fatal result/exception과 alert를 사용한다. + +### 15.3 record metadata와 policy SSOT + +```java +public record CacheRecordMetadata( + String sourceRevision, + CacheRecordIntent intent) {} +``` + +`CacheRecordIntent`는 `UPSERT` 또는 `ONLY_IF_SOURCE_REVISION_NEWER`처럼 application-visible +consistency 의도만 표현한다. + +TTL, jitter, maximum bytes, codec, compression, technical retry는 region descriptor가 유일한 +SSOT다. caller가 invocation마다 override하지 않는다. + +startup의 `CacheRegionPolicyCompiler`가: + +```text +code-declared semantic policy ++ environment operational bounds ++ provider capability limits +-> immutable EffectiveCacheRegionPolicy(revision, digest) +``` + +를 만들고 binding adapter에 freeze한다. rolling request마다 policy가 바뀌지 않는다. + +### 15.4 mutation outcome + +```text +RECORDED +NOT_RECORDED_CONDITION +NOT_RECORDED_PROVIDER_POLICY +DEGRADED_UNAVAILABLE +INDETERMINATE +``` + +cache-aside load 성공 후 cache write가 unavailable이어도 source response는 보통 성공한다. 그러나 +metric과 degraded result는 남긴다. oversize/TTL/codec 같은 technical reason은 adapter +telemetry에 있고 application은 provider policy상 record되지 않았다는 사실만 본다. +codec/programming error는 이 downgrade 대상이 아니다. + +### 15.5 cache-aside executor + +각 use case가 같은 cache recipe를 다시 구현하지 않도록 application-core에 framework-free +`CacheAsideExecutor`를 제공한다. + +```java +CacheResult getOrLoad( + K key, + CacheRegionPort region, + CacheSourceLoader sourceLoader) +``` + +region-specific compiled application policy는 executor construction 시 주입되고 호출마다 전달하지 +않는다. + +```java +public interface CacheSourceLoader { + SourceLoadOutcome load(K key, CancellationToken cancellation); +} + +public sealed interface SourceLoadOutcome { + record Loaded(V value, String sourceRevision) implements SourceLoadOutcome {} + record AuthoritativeAbsent( + AuthoritativeAbsence reason, String sourceRevision) implements SourceLoadOutcome {} + record TransientFailure(SourceFailure failure) implements SourceLoadOutcome {} + record PermanentFailure(SourceFailure failure) implements SourceLoadOutcome {} + record Cancelled() implements SourceLoadOutcome {} +} +``` + +`SourceFailure`은 bounded application error category/code와 original cause를 보존하되 cause +message를 Redis/log/tag에 serialize하지 않는다. unclassified thrown exception은 +`PermanentFailure`처럼 조용히 cache하지 않고 원래 예외를 보존해 전파한다. + +`CacheResult`는 최소: + +```text +FreshHit +StaleHit +LoadedFromSource +AuthoritativeAbsent +StaleFallbackAfterTransientFailure +DegradedSourceResult +``` + +를 구분한다. + +executor가 소유한다. + +- lookup outcome 해석; +- source fallback; +- local single-flight; +- `AuthoritativeAbsent`만 negative cache; +- transient/permanent source failure를 negative cache하지 않음; +- stale-if-error; +- refresh claim; +- write outcome 기록 hook; +- caller cancellation/deadline 전파. + +executor가 소유하지 않는다. + +- Redis serialization; +- database transaction; +- domain authorization; +- source error/absence classification의 business rule; +- HTTP response mapping. + +### 15.6 region descriptor + +각 region은 code/config의 typed descriptor로 선언한다. + +```text +regionId +required/optional +value schema/codec +positive TTL +negative TTL +soft/hard TTL +jitter +maximum payload +failure mode +stale-if-error +stampede strategy +invalidation strategy +read consistency +metrics cardinality key +``` + +arbitrary runtime user input으로 region을 만들지 않는다. startup에서 binding과 codec uniqueness를 +검증한다. + +## 16. Cache strategy catalog + +### 16.1 cache-aside baseline + +```text +lookup + HIT -> return + MISS -> load source -> store -> return + UNAVAILABLE -> policy에 따라 source load -> degraded return +``` + +source of truth는 Redis가 아니다. source loader failure와 cache failure를 별도로 분류한다. + +장점: + +- 명확한 ownership; +- only-read 데이터에 적합; +- Redis outage에서 source fallback 가능. + +위험: + +- miss burst; +- stale entry; +- DB와 cache dual-write gap; +- source overload. + +따라서 single-flight, TTL jitter, bounded fallback, invalidation을 함께 설계한다. + +### 16.2 negative cache + +다음처럼 “존재하지 않음”이 source에서 확정된 경우만 cache한다. + +- authoritative not-found; +- deterministic empty query; +- permission과 무관한 public absence. + +다음은 negative cache하지 않는다. + +- timeout; +- 5xx; +- authorization denial을 다른 principal과 공유; +- transient replication lag; +- validation/programming error. + +negative TTL은 positive TTL보다 짧고 별도 policy다. attacker가 random key로 negative entry를 +폭증시키지 못하도록 subject normalization, admission policy, cardinality budget을 둔다. + +### 16.3 stale-while-revalidate + +entry는 soft/hard expiry를 갖는다. + +```text +now < soft -> FRESH +soft <= now < hard -> STALE, 한 worker refresh +hard <= now -> MISS, source load 필요 +``` + +stale data를 반환해도 되는 query에만 사용한다. authorization, balance, inventory reservation, +revocation처럼 stale가 위험한 데이터에는 적용하지 않는다. + +### 16.4 stale-if-error + +source load가 transient failure일 때 hard expiry 전 stale 값을 반환할 수 있다. 반환 결과에는 +`source=STALE_FALLBACK`, age, policy revision을 내부적으로 남긴다. + +stale 최대 age를 무한히 연장하지 않는다. Redis write 실패를 이유로 hard expiry를 client에서 +임의 연장하지 않는다. + +### 16.5 refresh-ahead + +read traffic이 없어도 반드시 warm해야 하는 bounded hot set에만 사용한다. + +- region이 refresh 대상 key 목록을 소유; +- scheduler queue bounded; +- per-key single-flight; +- shutdown cancellation; +- source failure backoff; +- full keyspace `SCAN`으로 대상 발견 금지. + +일반 cache의 기본은 아니다. + +### 16.6 probabilistic early refresh + +hot key가 동시에 soft expiry에 도달하는 것을 줄이기 위해 remaining TTL, prior load duration, +bounded random 값을 사용해 일부 request만 일찍 refresh한다. + +이 전략은 correctness가 아니라 load smoothing이다. 확률 식과 upper bound를 descriptor에 +versioning하고 deterministic property test를 둔다. + +### 16.7 write-through + +application write와 cache write를 함께 호출할 수 있으나 DB와 Redis가 한 transaction이라는 뜻은 +아니다. + +```text +DB commit succeeds +Redis update fails +``` + +경로가 존재한다. baseline은 blind value update보다 after-commit invalidate를 선호한다. + +### 16.8 write-behind + +in-memory executor가 Redis/DB에 나중에 쓰는 형태는 production baseline이 아니다. write-behind가 +필요하면 durable outbox/stream, retry, ordering, terminal failure, reconciliation을 가진 별도 +workflow로 설계한다. + +### 16.9 L1 local + L2 Redis + +optional profile: + +```text +request -> bounded local L1 -> Redis L2 -> source +``` + +요구사항: + +- L1 maximum weight와 expiry; +- L1 entry는 L2 hard expiry를 넘지 않음; +- invalidation disconnect 시 L1 전체 flush; +- per-region enable; +- local stale age metric; +- Pub/Sub/tracking 유실 시 TTL recovery; +- session/idempotency/strict rate에 적용 금지. + +### 16.10 admission + +모든 source result를 cache하지 않는다. + +- payload size; +- expected reuse; +- load cost; +- tenant fairness; +- error/negative classification; +- sensitive data classification; +- cardinality budget. + +low-reuse high-cardinality scan 결과를 admission하지 않아 cache pollution을 줄인다. + +## 17. Cache invalidation과 consistency + +### 17.1 source of truth + +Redis cache는 source of truth가 아니다. cache read consistency는 region descriptor에 다음 중 +하나로 표시한다. + +```text +BEST_EFFORT +BOUNDED_STALENESS +READ_AFTER_INVALIDATION +SOURCE_REVISION_GUARDED +``` + +`STRONG`이나 `LINEARIZABLE`은 일반 DB + Redis cache 조합에 제공하지 않는다. + +### 17.2 write ordering + +금지: + +```text +cache delete +DB transaction +``` + +DB transaction이 rollback하면 유효 entry만 제거되어 불필요한 load가 생긴다. 더 위험한 구현은 +transaction 안에서 cache를 update한 뒤 DB가 rollback하는 것이다. + +baseline: + +```text +DB transaction commit +after-commit cache invalidate +``` + +process가 commit 직후 죽으면 invalidate가 누락될 수 있으므로 TTL이 최종 복구 경계다. + +### 17.3 reliable invalidation + +bounded stale만으로 충분하지 않으면 DB transaction에 invalidation intent를 outbox로 함께 append한다. + +```text +business update + outbox invalidation intent -- same DB transaction +outbox relay -> cache invalidation consumer +``` + +outbox/messaging leaf와 Redis leaf가 서로 의존하지 않는다. application event/intent와 bootstrap +composition을 통해 연결한다. + +CDC도 사용할 수 있다. 그러나 table-change를 cache key로 변환하는 mapping, ordering, +checkpoint, replay, schema evolution을 별도 consumer가 소유한다. “Debezium을 붙이면 cache +consistency가 해결된다”고 설명하지 않는다. + +### 17.4 stale refill race + +다음 race를 고려한다. + +```text +R1: cache miss +R1: old DB value load +W : DB update + cache invalidate +R1: old value cache put +``` + +단순 delete는 stale value를 다시 채울 수 있다. 해결 선택지는: + +1. source revision/version을 entry에 저장하고 conditional put; +2. generation ID를 read 시작 시 capture하고 같은 generation에만 put; +3. invalidation event의 revision보다 오래된 put 거절; +4. 아주 짧은 TTL로 bounded risk 수용. + +baseline R2는 source가 revision을 제공할 수 있으면 `REPLACE_IF_SOURCE_REVISION`, 그렇지 않으면 +generation capture를 사용한다. + +### 17.5 generation protocol + +```text +g = read/current generation +lookup key(g, logicalKey) +load source +put key(g, logicalKey) // captured g, current generation 재조회 금지 +``` + +invalidation은 generation을 새 random value로 바꾼다. load 중 invalidation이 일어나면 old +generation에 stale put이 되지만 new readers는 새 generation만 본다. + +generation bump response가 유실되면 result는 `INDETERMINATE`다. caller는 current generation을 +inspect하고 desired operation token의 audit record가 있으면 reconcile한다. cache correctness가 +TTL로 충분한 region은 duplicate bump를 허용하되 cold-cache 영향만 기록할 수 있다. + +### 17.6 per-key invalidate + +per-key invalidation은 idempotent delete다. timeout 후 재시도해도 최종 absent가 목적이므로 retry +safety가 높다. 단, stale refill race를 막는 revision/generation protocol과 함께 써야 한다. + +large value는 `DEL`의 synchronous deallocation latency를 피하기 위해 supported deployment에서 +`UNLINK`를 사용할 수 있다. key 존재 여부를 authoritative receipt로 해석하지 않는다. + +### 17.7 Pub/Sub invalidation + +Pub/Sub과 keyspace notification은 best-effort hint다. + +- subscriber disconnect 중 event 유실; +- reconnect replay 없음; +- Cluster node별 subscription semantics; +- expiry event가 TTL 0 시각에 정확히 오지 않음. + +따라서 L1 flush/refresh hint로만 사용하고 hard TTL, generation, schema version을 recovery +boundary로 유지한다. + +### 17.8 delete storm + +mass invalidation 직후 모든 pod가 source를 동시에 load할 수 있다. + +- generation bump; +- randomized prewarm; +- source concurrency budget; +- local/distributed single-flight; +- stale grace; +- queue/backpressure; +- progressive rollout + +을 조합한다. invalidation producer가 모든 key를 즉시 delete하는 방식은 기본이 아니다. + +## 18. Stampede와 source protection + +### 18.1 방어 계층 + +권장 순서: + +1. positive/negative bounded TTL; +2. TTL jitter; +3. local single-flight; +4. soft TTL + stale serve; +5. probabilistic early refresh; +6. distributed refresh lease; +7. source bulkhead; +8. load shedding. + +분산 lock 하나로 stampede 전체를 해결하지 않는다. + +### 18.2 TTL jitter + +동일 batch로 생성된 entry가 같은 시점에 만료되지 않게 actual TTL을 bounded range에서 정한다. + +```text +actual = configured * (1 + sample[-jitter, +jitter]) +``` + +규칙: + +- cryptographic randomness 불필요; +- hard minimum 보장; +- policy revision에 jitter strategy 기록; +- test에서는 seeded source로 deterministic 검증; +- compliance/authorization expiry를 늦춰서는 안 됨. + +### 18.3 local single-flight + +같은 process의 동시 miss는 한 loader future를 공유한다. + +필수 bound: + +- maximum in-flight keys; +- per-load deadline; +- waiter limit; +- completed entry 즉시 제거; +- cancellation semantics; +- loader exception fan-out; +- abandoned future reaper; +- key digest만 diagnostic에 사용. + +single-flight map이 cache처럼 무한히 남지 않는다. + +### 18.4 distributed refresh lease + +다중 pod에서 한 owner만 refresh를 시도하도록 짧은 efficiency lease를 쓸 수 있다. + +```text +claim refresh(key, owner, leaseTtl) + acquired -> source load -> captured generation put -> owner-safe release + contended -> stale serve or bounded wait +``` + +보장: + +- 같은 Redis primary가 정상인 동안 duplicate load 감소; +- owner-safe release; +- lease TTL로 crashed loader 회복. + +비보장: + +- failover/partition에서 전역 단일 loader; +- source side effect correctness; +- loader completion 전 lease 유지. + +loader는 read-only/idempotent여야 한다. lease를 잃어 duplicate load가 발생해도 business side +effect가 생기지 않아야 한다. + +### 18.5 double check + +distributed refresh lease를 획득한 뒤 cache를 다시 읽는다. 다른 owner가 먼저 채웠을 수 있다. + +```text +miss -> claim -> lookup again -> still miss면 load +``` + +두 번째 lookup을 생략하면 불필요한 source load가 생긴다. + +### 18.6 lease TTL + +lease TTL은 source load deadline보다 길고 shutdown/GC pause risk를 고려하지만 무한하지 않다. +load duration distribution을 관측해 설정한다. + +watchdog renewal은 baseline cache refresh에 필수로 두지 않는다. refresh가 TTL보다 길면: + +- load를 cancel; +- duplicate load를 허용; +- source query를 paging/background job으로 바꿈 + +중 하나를 선택한다. + +### 18.7 source fallback budget + +Redis outage에서 모든 request를 DB로 보내면 cache failure가 DB outage로 확대된다. + +region은 다음을 선언한다. + +```text +maximum concurrent source loads +maximum queued waiters +load deadline +overload outcome +stale fallback +``` + +cache fail-open은 unlimited fail-open이 아니다. + +### 18.8 hot key + +한 key가 한 Redis shard와 한 source row에 집중될 수 있다. + +- request coalescing; +- stale serve; +- refresh ahead; +- read replica/cache replica; +- payload split 금지 여부; +- local L1; +- hot-key metric/sample + +을 검토한다. hash tag를 바꿔 동일 logical key를 여러 shard에 복제하면 invalidation과 consistency +cost가 늘어나므로 명시적 replicated-cache strategy일 때만 허용한다. + +## 19. Edge rate-limit contract + +### 19.1 소유권 + +transport edge rate limit은 `shared-contract`가 framework-neutral value contract를 소유한다. + +```java +public interface EdgeRateLimitPort { + RateLimitOutcome evaluate(RateLimitRequest request); +} +``` + +`adapter:inbound:web`가 다음을 수행한다. + +- trusted client IP 해석; +- authenticated principal/tenant/API key 해석; +- normalized route/operation ID 선택; +- policy ID 선택; +- bootstrap이 주입한 framework-neutral `EdgeSubjectPseudonymizer`로 canonical subject를 + versioned HMAC digest로 변환; +- HTTP response/header mapping. + +`EdgeSubjectPseudonymizer` contract는 `shared-contract`, secret-backed 구현과 rotation은 +`adapter:outbound:identifier` 또는 별도 approved provider, wiring은 `app-bootstrap`이 소유한다. +inbound는 HMAC secret을 직접 resolve하지 않고 Redis provider는 raw identity나 HTTP route를 +직접 파싱하지 않는다. + +### 19.2 request + +```java +public record RateLimitRequest( + String policyId, + String subjectDigest, + long cost, + String evaluationId, + Instant callerDeadline) {} +``` + +- `policyId`: bounded allowlisted ID; +- `subjectDigest`: inbound가 만든 versioned HMAC digest; +- `cost`: positive bounded integer; +- `evaluationId`: optional retry dedup token; +- `callerDeadline`: transport deadline budget. + +algorithm, Redis key, window timestamp는 request에 넣지 않는다. provider의 policy registry가 +소유한다. + +### 19.3 outcome과 decision + +```java +public sealed interface RateLimitOutcome { + record Evaluated(RateLimitDecision decision) implements RateLimitOutcome {} + record Degraded(RateLimitDecision decision, DegradationReason reason) + implements RateLimitOutcome {} + record Unavailable( + String policyId, Duration retryAfter, FailureCategory category) + implements RateLimitOutcome {} + record Indeterminate( + String policyId, String evaluationId, Duration retryAfter) + implements RateLimitOutcome {} +} +``` + +`allowed`가 의미 있는 경우에만 decision이 존재한다. + +```java +public record RateLimitDecision( + boolean allowed, + long limit, + long remaining, + Duration retryAfter, + Instant resetAt, + String policyId, + String policyRevision, + DecisionSource source, + DecisionCertainty certainty) {} +``` + +`DecisionSource`: + +```text +GLOBAL_REDIS +LOCAL_EMERGENCY +FAIL_OPEN_POLICY +SHADOW +``` + +`DecisionCertainty`는 `CERTAIN | APPROXIMATE_ALGORITHM`만 가진다. mutation certainty가 없는 +경우 decision을 만들지 않고 `Indeterminate`를 반환한다. + +기본 HTTP mapping: + +| Outcome | Mapping | +| --- | --- | +| `Evaluated(allowed=true)` | request 진행, signaling header | +| `Evaluated(allowed=false)` | `429`, decision의 `Retry-After` | +| `Degraded(allowed=true/false)` | 해당 allow/429 + internal degraded telemetry | +| `Unavailable` | `503`, outcome의 `Retry-After` | +| `Indeterminate` | strict/default `503`; policy가 local fallback을 성공하면 `Degraded`로 변환 | + +security product가 unavailable을 429로 숨겨야 하면 named HTTP mapping policy와 contract test를 +별도 둔다. current 고정 1초 mapping은 제거한다. + +### 19.4 policy + +```java +public record RateLimitPolicy( + String id, + String revision, + RateLimitAlgorithm algorithm, + SubjectDimensions dimensions, + RateParameters parameters, + FailurePolicy failurePolicy, + DedupPolicy dedupPolicy, + CardinalityBudget cardinalityBudget, + boolean shadow) {} +``` + +global algorithm 하나가 아니라 policy별로 선택한다. + +### 19.5 subject dimension + +가능한 dimension: + +```text +global +tenant +principal +api-key +client-ip +route/operation +resource class +``` + +raw value는 key, log, metric에 넣지 않는다. authenticated principal에도 route/policy dimension을 +포함해 현재의 global quota collision을 제거한다. + +### 19.6 business quota + +“한 고객이 하루에 export 100개 생성 가능”처럼 domain/application rule인 quota는 별도 +`BusinessQuotaPort`와 use-case policy다. HTTP abuse rate limit과 공유하면 transport 우회, +batch consumer, gRPC 호출에서 rule이 사라진다. + +## 20. Rate-limit algorithm catalog + +### 20.1 공통 원칙 + +모든 algorithm은: + +- Function/Lua 한 번으로 read/decide/write; +- Redis `TIME` 기반 server time; +- bounded integer millisecond/fixed-point arithmetic; +- state TTL; +- policy revision key; +- maximum cost/state validation; +- Cluster same-slot; +- typed result; +- concurrency property test + +를 갖는다. + +client clock은 response `resetAt` 표시 보조로만 사용한다. enforcement calculation은 pod clock +skew의 영향을 줄이기 위해 Redis server time을 사용한다. + +program은 `TIME`을 한 번만 읽고 stored `lastObservedMillis/windowId`와 비교한다. + +- `serverNow < lastObserved`: `effectiveNow=max(serverNow,lastObserved)`로 clamp; +- token bucket/GCRA/sliding state를 뒤로 이동하지 않음; +- fixed window는 last accepted window ID보다 작은 window로 회귀하지 않음; +- forward jump의 refill은 capacity에서 saturation; +- configured unsafe clock-step threshold 초과 시 `CLOCK_UNSAFE` outcome; +- strict policy는 fail closed, availability policy는 explicit degraded fallback. + +clock clamp가 Redis lease의 wall-clock safety를 strong하게 만들지는 않는다. + +### 20.2 fixed window + +state: + +```text +windowId -> consumed +``` + +atomic steps: + +1. `windowId=floor(effectiveNow/windowMillis)` 계산; +2. window interval은 `[windowId*windowMillis, (windowId+1)*windowMillis)`; +3. `cost <= limit`과 overflow 검증; +4. `consumed + cost <= limit`일 때만 counter를 증가; +5. first accepted request에서 TTL을 + `windowEnd-effectiveNow+cleanupGraceMillis`로 설정; +6. denied request는 baseline에서 counter를 소비하지 않음; +7. `remaining=max(0, limit-newConsumed)`; +8. deny의 `retryAfter=windowEnd-effectiveNow`, `resetAt=windowEnd`. + +특성: + +- O(1) state; +- 이해하기 쉬움; +- boundary 직전/직후에 두 window quota를 연속 사용 가능; +- global smoothness가 필요 없는 단순 protection에 적합. + +현재 local fixed-window를 Redis로 옮기는 최소 migration algorithm이지만 모든 policy의 default는 +아니다. + +### 20.3 sliding-window log + +state: + +```text +sorted set(member=evaluationId-or-unique-token, score=serverMillis) +``` + +atomic steps: + +1. interval은 `(effectiveNow-windowMillis, effectiveNow]`; +2. `score <= effectiveNow-windowMillis` member를 bounded trim; +3. current count/cost 계산; +4. 허용 시 member 추가; +5. key TTL 설정; +6. oldest member에서 retry/reset 계산. + +특성: + +- event-level 정확한 sliding window; +- insert는 O(log N), `ZREMRANGEBYSCORE` trim은 O(log N + M); +- `M`은 한 invocation에서 제거하는 event 수이므로 per-call trim bound와 incremental cleanup 필요; +- event 수만큼 memory; +- attacker/high-volume policy에서 expensive; +- maximum members와 maximum policy rate를 startup에서 제한. + +exact-log v1 profile은 `cost=1`만 허용한다. denied request는 member를 추가하지 않는다. +`retryAfter=max(1, oldestAcceptedScore+windowMillis-effectiveNow)`, TTL은 +`windowMillis+cleanupGraceMillis`다. cost가 1보다 크면 member-per-cost로 확장하지 않고 별도 +bounded weighted-log revision을 설계하거나 다른 algorithm을 선택한다. + +### 20.4 sliding-window counter + +state: + +```text +previousWindowCount +currentWindowCount +``` + +estimate: + +```text +windowId = floor(effectiveNow/windowMillis) +elapsed = effectiveNow - windowId*windowMillis +SCALE = 1_000_000 +previousWeight = ceil((windowMillis-elapsed) * SCALE / windowMillis) +weightedScaled = current*SCALE + previous*previousWeight +``` + +두 key 또는 한 hash를 사용하며 같은 slot이다. + +특성: + +- O(1) state; +- fixed window보다 boundary burst 완화; +- exact log가 아닌 근사치; +- conservative `ceil` rounding; +- accepted current/previous count가 각 limit 이하일 때 exact log와의 absolute error upper bound는 + `previousWindowCount <= limit`; +- 일반 API의 production option. + +allow iff `weightedScaled + cost*SCALE <= limit*SCALE`; denied request는 current count를 +증가시키지 않는다. state는 one versioned hash에 current/previous window ID/count와 +`lastObservedMillis`를 저장하고 TTL은 `2*windowMillis+cleanupGraceMillis`다. + +remaining을 exact quota처럼 표시하지 않고 `APPROXIMATE_ALGORITHM` certainty를 반환한다. + +### 20.5 token bucket + +state: + +```text +tokensFixedPoint +lastRefillMillis +``` + +parameters: + +```text +capacity +refillTokens +refillPeriod +requestCost +``` + +atomic steps: + +1. `SCALE=1_000_000` micro-token으로 capacity/refill/cost 변환; +2. `elapsed=max(0,effectiveNow-lastRefillMillis)`; +3. `refill=floor(elapsed*refillScaled/refillPeriodMillis)`, multiply overflow 선검증; +4. `available=min(capacityScaled, storedTokens+refill)`; +5. `available>=costScaled`이면 차감, 아니면 state token을 차감하지 않음; +6. deny의 + `retryAfter=ceil((costScaled-available)*refillPeriodMillis/refillScaled)`; +7. `resetAt`은 bucket full 시각, + `ceil((capacityScaled-newTokens)*refillPeriodMillis/refillScaled)`; +8. TTL은 + `ceil(capacityScaled*refillPeriodMillis/refillScaled)+cleanupGraceMillis`. + +특성: + +- average rate와 burst capacity를 독립 제어; +- O(1) state; +- burst를 허용하는 API에 권장; +- floating point 대신 bounded fixed-point integer 사용; +- long idle 뒤 overflow를 막는 saturation arithmetic 필요. + +정책 요구가 명확하지 않으면 “token bucket이 무조건 최고”로 고정하지 않는다. + +### 20.6 leaky bucket + +두 의미를 구분한다. + +`policing`: + +- 일정 rate를 넘는 요청을 즉시 reject; +- compact state로 구현 가능. + +`shaping`: + +- 허용 실행 시각을 계산해 queue에서 지연; +- synchronous HTTP request를 Redis 안이나 servlet thread에서 대기시키지 않음; +- background workflow/dispatcher가 bounded queue와 deadline을 소유할 때만 사용. + +### 20.7 GCRA + +state: + +```text +theoreticalArrivalTime +``` + +장점: + +- compact O(1) state; +- smooth quota; +- burst tolerance 표현. + +위험: + +- arithmetic/rounding 이해가 어렵고 operator 설명 비용이 큼; +- retry/reset 의미가 policy와 정확히 맞아야 함. + +advanced opt-in으로 제공하며 token bucket과 동일 결과가 아님을 contract test로 고정한다. + +### 20.8 concurrency limiter + +동시에 실행 중인 request 수를 제한하는 것은 rate limit이 아니다. + +별도 `ConcurrencyPermitPort`: + +```text +acquire(subject, ttl) +renew(owner) +release(owner) +``` + +를 사용한다. permit leak, owner-safe release, lease expiry, queue bound를 다룬다. token bucket +cost로 concurrency를 흉내 내지 않는다. + +### 20.9 algorithm comparison + +| Algorithm | State | 정확성/특성 | 권장 | +| --- | --- | --- | --- | +| Fixed window | O(1) | boundary burst | simple protection | +| Sliding log | O(events) | exact sliding | low-volume high-value | +| Sliding counter | O(1) | bounded approximation | general API | +| Token bucket | O(1) | average + burst | burst-tolerant API | +| Leaky policing | O(1) | smooth rejection | no-burst policy | +| GCRA | O(1) | precise scheduling model | advanced | + +### 20.10 normative vectors와 readiness + +algorithm manifest에는 exact golden vector가 들어간다. + +| Algorithm | Input/state | Expected | +| --- | --- | --- | +| fixed | window 1000ms, limit 2, at 999ms accepted=1, cost=1 | allow, remaining 0, reset 1000ms | +| fixed | same state at 999ms, cost=1 | deny, counter unchanged, retry 1ms | +| fixed | new request at 1000ms | new window, allow | +| sliding counter | previous=10, current=0, elapsed=500/1000ms | weighted=5 with configured scale/ceil | +| sliding counter | backward clock | window ID/state never regress, `CLOCK_UNSAFE` if threshold exceeded | +| token bucket | capacity 10, tokens 0, refill 10/1000ms, elapsed 250ms | 2.5 scaled tokens before cost | +| token bucket | available < cost | deny, token balance not deducted, exact ceil retry | +| sliding log | event score exactly `now-window` | trimmed; interval lower bound exclusive | + +R2 baseline algorithms are fixed window, sliding counter, token bucket. Sliding log, GCRA, leaky +policing/shaping remain advanced until their own manifest, vector, state-growth and topology evidence +card passes. Test reference implementation uses the formulas above, not an independently guessed +algorithm. + +## 21. Rate-limit policy composition과 failure + +### 21.1 hierarchical policy + +한 request에 global + tenant + principal + route limit이 동시에 적용될 수 있다. + +선택지는: + +1. 같은 slot의 bounded composite program으로 all-or-nothing evaluate; +2. 독립 policy를 순서대로 evaluate; +3. approximate/local upper-tier와 exact lower-tier 조합. + +서로 다른 slot의 evaluation을 atomic하다고 표현하지 않는다. + +순차 evaluate에서 앞 policy token을 소비한 뒤 뒤 policy가 deny할 수 있다. refund는 또 다른 race를 +만든다. 이 conservative consumption을 명시하거나 same-slot composite를 사용한다. + +### 21.2 hot global key + +global policy 하나는 모든 traffic이 한 key/slot에 모인다. 다음을 검토한다. + +- ingress/gateway 상위 limiter; +- shard별 approximate pre-limit; +- tenant/route partition; +- local emergency ceiling; +- dedicated rate-limit deployment; +- actual command latency/capacity evidence. + +global exactness를 위해 한 hot key를 무한 확장할 수 있다고 가정하지 않는다. + +### 21.3 evaluation dedup + +response를 잃고 동일 request가 재시도되면 token이 두 번 차감될 수 있다. + +strict cost boundary는 optional `evaluationId` dedup을 사용한다. + +```text +evaluationId -> prior decision, short TTL +``` + +dedup record와 algorithm state는 같은 slot/program에서 처리한다. memory cost가 있으므로 policy별 +enable, maximum IDs, TTL을 둔다. + +dedup이 꺼져 있으면 at-least-once evaluation과 possible double charge를 descriptor에 명시한다. + +### 21.4 failure modes + +| Policy | Redis failure | +| --- | --- | +| abuse/security boundary | fail closed 또는 bounded local deny-first | +| monetary/cost protection | fail closed | +| general availability throttle | bounded local emergency limiter | +| non-critical smoothing | explicit fail open | +| shadow policy | allow + telemetry | + +global `FAIL_OPEN=true`는 없다. + +### 21.5 local emergency limiter + +Redis unavailable일 때 선택 가능한 fallback: + +- process-local; +- global quota보다 conservative; +- bounded maximum keys/weight; +- short TTL; +- no persistence; +- `LOCAL_EMERGENCY` decision; +- Redis recovery 후 자동 drain; +- pod 수에 따라 global exactness가 없음을 명시. + +fallback map도 current local implementation처럼 unbounded면 안 된다. + +primary/fallback ownership: + +- `shared-contract`: `EdgeRateLimitPort`, outcome, provider-neutral fallback policy value; +- Redis leaf: `provider=redis` primary; +- inbound web: bounded `local-emergency` provider와 HTTP enforcement; +- app-bootstrap: primary와 optional degraded provider를 explicit selection으로 조립하는 composite; +- application/domain: transport quota fallback 없음. + +`degraded-provider=local-emergency`일 때만 local map/sweeper/metric bean을 만든다. Redis +`Unavailable` 또는 reconcilable timeout만 composite fallback 후보이며 codec/program/config +failure에는 fallback하지 않는다. local result는 항상 `Degraded(source=LOCAL_EMERGENCY)`이고 +global exactness를 광고하지 않는다. + +`perPodLimit=floor(globalLimit * perPodShare)`로 capacity/refill/window limit을 보수적으로 줄인다. +결과가 0이면 해당 policy는 local allow를 하지 않고 fail closed한다. +`perPodShare * assumedMaximumPods <= 1`을 검증하지만 +실제 pod가 가정을 초과하거나 traffic이 불균등하면 global quota가 아님을 descriptor/alert에 +남긴다. maximum entries, entry TTL, in-flight, cleanup work도 설정 bound를 초과하지 않는다. + +### 21.6 timeout certainty + +rate program timeout 뒤 차감 여부가 indeterminate일 수 있다. + +- evaluation dedup enabled: 같은 ID로 inspect/retry; +- strict policy without dedup: deny 또는 retry-after; +- availability policy: local emergency decision; +- 절대로 timeout을 ordinary allow로 조용히 바꾸지 않음. + +### 21.7 shadow mode + +정책 migration은 실제 deny 없이 decision을 기록하는 shadow mode를 지원한다. + +- allowed response; +- would-have-denied metric; +- no subject metric tag; +- bounded sample log; +- state cost는 실제와 동일하므로 capacity 고려; +- shadow가 security control로 오인되지 않게 descriptor 표시. + +## 22. Efficiency lease contract + +### 22.1 기존 port의 위치 + +현재 `DistributedLockPort`는 “efficiency lock, DB constraint가 correctness authority”라는 문서가 +있다. 이 의미는 유지한다. 기존 `tryAcquire(...)->DistributedLock.close()`는 compatibility +facade로 두고 새 v2 contract로 구현한다. + +### 22.2 v2 request/outcome + +```java +public interface DistributedLeasePort { + LeaseAttempt newAttempt(String operationId); + LeaseAcquireOutcome tryAcquire(LeaseRequest request); + LeaseInspectionOutcome inspect(LeaseInspectionRequest request); +} +``` + +```java +public record LeaseAttempt(String ownerToken, String operationId) {} + +public record LeaseRequest( + String purpose, + String resourceDigest, + Duration waitTimeout, + Duration leaseTtl, + LeaseAttempt attempt) {} + +public record LeaseInspectionRequest( + String purpose, String resourceDigest, LeaseAttempt attempt) {} +``` + +outcome: + +```text +Acquired(LeaseHandle) +ReplayedSameOperation(LeaseHandle) +Contended(retryAfter) +OwnerOperationConflict +Unavailable(category) +Overloaded +Indeterminate(operationId) + +Inspection: + Owned(LeaseHandle) | Absent | NotOwner | OwnerOperationConflict | + Unavailable | Indeterminate +``` + +`newAttempt`는 network/Redis side effect 없이 secure random opaque owner token을 만든다. caller는 +최초 send 전에 반환된 attempt를 보관하고 retry/inspect에 같은 값을 사용한다. adapter 내부에서 +send 직전에 token을 만들어 caller에게 숨기는 구현은 금지한다. + +### 22.3 lease handle + +```java +public interface LeaseHandle extends AutoCloseable { + String ownerToken(); + String operationId(); + Instant acquiredAt(); + Duration remainingValidity(); + boolean isUsableFor(Duration workBudget); + Instant observedServerExpiry(); // telemetry only + LeaseState state(); // ACTIVE | LOST | RELEASED | UNKNOWN + LeaseRenewOutcome renew(); + LeaseReleaseOutcome release(); +} +``` + +application이 Redis key를 보지 않는다. owner token은 secure random opaque value이며 log/metric에 +남기지 않는다. + +### 22.4 acquire + +single-primary baseline은 owner와 operation ID를 한 bounded envelope에 저장한다. + +```text +SET leaseKey ownerOperationEnvelope NX PX leaseTtl +``` + +same-attempt replay와 inspect가 필요한 R2 provider는 `lease-acquire-v1`/`lease-inspect-v1` +program으로 owner+operation을 비교한다. finite wait는 client에서 bounded backoff+jitter로 +반복하며 한 Redis script가 wait하지 않는다. + +### 22.5 release + +금지: + +```text +DEL leaseKey +``` + +old owner lease가 만료된 뒤 new owner가 acquire했을 수 있다. + +필수: + +```text +if GET leaseKey == ownerToken then DEL leaseKey +``` + +를 one atomic program으로 실행한다. + +release outcome: + +```text +RELEASED +ALREADY_ABSENT +NOT_OWNER +INDETERMINATE +UNAVAILABLE +``` + +기존 `void close()` compatibility facade는 release result를 caller에게 전달할 수 없다. +따라서 `NOT_OWNER/INDETERMINATE`는 telemetry와 lease-lost callback에만 남기고, 결과에 따라 +application policy를 실행해야 하는 consumer는 반드시 v2 `release()` outcome으로 migration한다. +legacy facade가 silent success를 보장한다고 문서화하지 않는다. + +### 22.6 renew + +renew도 owner compare 후 TTL을 바꾼다. + +```text +if GET leaseKey == ownerToken then PEXPIRE leaseKey newTtl +``` + +renew timeout은 lease가 연장되었는지 알 수 없는 `INDETERMINATE`다. correctness-sensitive work는 +즉시 lease를 `UNKNOWN/LOST`로 보고 protected operation을 중단해야 한다. + +### 22.7 watchdog + +watchdog를 사용할 경우: + +- fixed cadence가 lease TTL보다 충분히 짧음; +- scheduling delay/GC pause 관측; +- renewal queue bounded; +- application deadline 이후 renew 금지; +- shutdown 시 new renew 중단; +- consecutive failure threshold가 아니라 validity deadline으로 lost 판단; +- handle state thread-safe. + +watchdog가 process pause나 failover를 제거하지 않는다. + +### 22.8 validity + +acquire response latency를 뺀 effective validity를 계산한다. + +```text +remainingValidity = + leaseTtl - localMonotonicElapsedSinceAcquireStart - driftBudget +``` + +remaining이 minimum protected-work budget보다 작으면 acquired result를 사용하지 않고 release한다. +`Instant` wall-clock은 authoritative validity 판단에 사용하지 않는다. `observedServerExpiry`는 +operator telemetry일 뿐이다. Redis clock step, failover, renewal timeout을 감지하면 wall-clock +추정과 무관하게 handle을 `UNKNOWN/LOST`로 전환한다. + +### 22.9 unknown acquire + +acquire command는 적용되었는데 response를 잃을 수 있다. caller가 보관한 같은 +`LeaseAttempt(ownerToken, operationId)`로 inspect하거나 acquire를 반복한다. live envelope가 +일치하면 같은 handle/remaining TTL을 `Owned`/`ReplayedSameOperation`으로 회수한다. owner는 같고 +operation이 다르면 conflict이며, absent 또는 inspect도 timeout이면 이전 acquire를 성공/실패로 +단정하지 않는다. + +random new token으로 즉시 재시도하면 self-contention이나 two-attempt confusion이 생긴다. + +### 22.10 사용 가능 범위 + +적합: + +- duplicate cache refresh 감소; +- duplicate scheduled cleanup 감소; +- cost가 낮고 idempotent한 background work; +- DB constraint가 최종 authority인 mutation의 contention 완화. + +부적합: + +- 결제 중복 방지의 유일한 장치; +- inventory invariant; +- unique ID authority; +- external device exclusive command; +- stale writer를 거절할 수 없는 storage write. + +## 23. Fencing과 coordination capability + +### 23.1 별도 contract + +fencing은 efficiency lease의 boolean option이 아니다. + +```java +public interface FencedLeasePort { + FencedLeaseAttempt newAttempt(String operationId); + FencedLeaseAcquireOutcome tryAcquire(FencedLeaseRequest request); + FencedLeaseInspectionOutcome inspect(FencedLeaseInspectionRequest request); + FencedLeaseRenewOutcome renew(FencedLeaseHandle handle, Duration leaseTtl); + FencedLeaseReleaseOutcome release(FencedLeaseHandle handle); +} +``` + +`FencedLeaseRequest`와 inspection request는 caller가 최초 send 전 받은 같은 +`FencedLeaseAttempt(ownerToken, operationId)`와 durable +`FencingResourceRegistration(resourceEpoch, registrationDigest)`를 포함한다. response-loss retry +중 adapter가 새 owner token/epoch를 만들지 않는다. + +handle: + +```text +ownerToken +operationId +fencingToken(resourceEpoch, counter) +remainingValidity/isUsableFor +renew/release/lost state +``` + +acquire outcome: + +```text +ACQUIRED(handle) +REPLAYED_SAME_OPERATION(handle) +CONTENDED(retryAfter) +OWNER_OPERATION_CONFLICT +FENCE_COUNTER_MISSING +FENCE_REGRESSION +FENCE_EXHAUSTED +EPOCH_MISMATCH +REGISTRATION_CONFLICT +UNAVAILABLE_BEFORE_SEND +INDETERMINATE(operationId) + +Inspection: + OWNED(handle) | ABSENT | NOT_OWNER | OWNER_OPERATION_CONFLICT | + FENCE_COUNTER_MISSING | EPOCH_MISMATCH | REGISTRATION_CONFLICT | + FENCE_REGRESSION | UNAVAILABLE | INDETERMINATE +Renew: + RENEWED | ABSENT | NOT_OWNER | TOKEN_MISMATCH | FENCE_COUNTER_MISSING | + EPOCH_MISMATCH | REGISTRATION_CONFLICT | FENCE_REGRESSION | + INDETERMINATE | UNAVAILABLE +Release: + RELEASED | ABSENT | NOT_OWNER | TOKEN_MISMATCH | FENCE_COUNTER_MISSING | + EPOCH_MISMATCH | REGISTRATION_CONFLICT | FENCE_REGRESSION | + INDETERMINATE | UNAVAILABLE +``` + +`Instant validUntil`은 authority가 아니며 §22와 같은 local monotonic budget을 사용한다. +inspection은 resource/owner/operation ID가 모두 같은 live owner record일 때만 기존 fencing +token과 remaining TTL을 돌려준다. + +fenced owner envelope는 일반 lease envelope와 schema/epoch/counter가 다르므로 generic +`lease-renew/release` program을 재사용하지 않는다. §13.10의 fenced-specific renew/release가 +handle의 epoch/counter와 current registration까지 비교한다. renew timeout은 handle을 +`UNKNOWN/LOST`로 만들고 자동 재시도하지 않으며, release timeout은 desired-absent repeat/inspect +전까지 `INDETERMINATE`다. + +### 23.2 protected resource requirement + +fencing token은 lock provider가 발급하는 것만으로 충분하지 않다. protected resource가 마지막 +accepted token을 저장하고: + +```text +incomingToken.epoch < lastAcceptedToken.epoch -> reject +incomingToken.epoch == lastAcceptedToken.epoch + && incomingToken.counter <= lastAcceptedToken.counter -> reject +``` + +해야 한다. epoch가 더 큰 token은 protected resource의 durable registration/activation과 +일치할 때만 받아들이며 Redis caller가 임의 epoch를 높일 수 없다. + +resource가 token을 검증할 수 없으면 `FENCED` guarantee를 광고하지 않는다. + +### 23.3 Redis counter failover + +fencing counter도 Redis 비동기 replication에서 acknowledged increment가 유실될 수 있다. promoted +replica가 더 낮은 token을 발급할 수 있다. + +이미 높은 token을 본 protected resource가 낮은 token을 거절하면 stale safety는 유지될 수 있지만, +counter가 high watermark를 넘어갈 때까지 새 work도 거절되어 availability가 떨어진다. + +따라서: + +- counter persistence/replication profile 명시; +- resource-side high watermark; +- token regression alert; +- recovery runbook; +- “Redis counter이므로 monotonic forever” 문구 금지. + +### 23.3.1 fenced acquire program과 counter lifecycle + +`fenced-lease-acquire`는 같은 slot의 두 key를 한 program에서 처리한다. + +```text +KEYS[1] = lease owner key +KEYS[2] = fence counter key +ARGV = expectedResourceEpoch, registrationDigest, ownerToken, + leaseTtlMillis, operationId, hardFailThreshold +``` + +first write 전에 key type, current owner, TTL, counter integer/range, provisioned epoch/digest를 +모두 검증한다. + +- live owner token과 operation ID가 모두 같으면 counter를 증가시키지 않고 기존 fencing token을 + `REPLAYED_SAME_OPERATION`으로 반환한다. +- owner token은 같지만 operation ID가 다르면 `OWNER_OPERATION_CONFLICT`다. +- 다른 live owner면 `CONTENDED`다. +- owner가 없을 때만: + +1. signed 64-bit counter를 1 증가; +2. new `(resourceEpoch, counter)` fencing token을 얻음; +3. owner token + epoch/counter + operation ID를 lease TTL과 함께 기록 + +한다. + +acquire 응답 유실 뒤 caller는 같은 owner/operation ID로 `inspect`하거나 동일 acquire를 반복한다. +live record가 남아 있으면 같은 fencing token을 회수하고, 이미 만료되었으면 새 operation으로 +정책상 재시도하되 이전 effect를 자동 성공/실패로 단정하지 않는다. inspect/replay에도 실패하면 +`INDETERMINATE`를 유지한다. + +counter 규칙: + +- coordination `noeviction` role; +- TTL 없음; +- resource retirement 없이 cleanup 금지; +- missing/regressed counter를 0으로 초기화하지 않음; +- request epoch/digest와 provisioned envelope가 다르면 `EPOCH_MISMATCH`/ + `REGISTRATION_CONFLICT`; +- `FENCE_REGRESSION/UNAVAILABLE`로 fail closed; +- signed 64-bit overflow 이전 configured hard-fail threshold; +- protected-resource high watermark와 operator recovery 필수. + +#### Counter registration과 restore + +missing counter는 “신규 resource”와 “Redis loss/restore”를 구분할 수 없으므로 request path에서 +`SET NX 0`으로 만들지 않는다. protected resource의 durable store가 다음 registration을 +authority로 소유한다. + +```text +resourceId +resourceEpoch +registrationDigest +fencingStatus = PENDING | ACTIVE | RETIRED +lastAcceptedHighWatermark +``` + +one-time provisioning protocol: + +1. protected resource 생성 transaction에서 random/durable epoch와 registration digest를 만들고 + `PENDING`, high watermark 0을 commit한다. +2. after-commit reconciler가 `fenced-counter-provision-v1`을 호출한다. program은 + `(epoch, registrationDigest, durableHighWatermark)` envelope를 no-TTL/noeviction key에 + `NX`로 만들며 같은 registration은 `ALREADY_SAME`, 다른 값은 conflict다. +3. Redis read-back receipt의 epoch/digest/counter를 검증한 뒤 durable row를 `ACTIVE`로 바꾼다. +4. `ACTIVE` 전에는 fenced acquire를 fail closed한다. + +DB commit 뒤 Redis provisioning 전 crash는 PENDING reconciler가 복구한다. Redis counter가 +missing인데 durable row가 `ACTIVE`이면 신규 resource로 재해석하지 않는다. reconciler/operator는 +protected resource의 durable high watermark와 epoch를 읽고, backup/incident evidence가 +충분할 때만 counter를 그 high watermark 이상으로 reprovision한다. 다음 acquire increment가 +반드시 마지막 accepted token보다 커야 한다. authoritative high watermark를 얻을 수 없으면 +availability를 닫은 채 복구하지 않는다. + +rollback/cleanup: + +- `PENDING`이며 protected write가 전혀 없을 때만 registration digest compare-delete 후 row rollback; +- `ACTIVE` counter는 application rollback이나 generic cache cleanup으로 삭제 금지; +- retirement는 durable `RETIRED` tombstone과 epoch를 남기고 모든 holder/work drain 및 retention + 뒤 operator workflow로 정리; +- resource ID 재사용 시 이전 epoch를 재사용하지 않음. + +resource epoch는 모든 fenced resource에서 항상 존재한다. cleanup은 epoch를 나중에 “추가”하는 +절차가 아니라 위 registration/retirement lifecycle과 `(epoch,counter)` 비교를 계속 보존한다. + +### 23.4 Redlock + +multi-master Redlock을 기본 correctness provider로 선택하지 않는다. + +이유: + +- finite lease와 wall-clock/drift 가정; +- network partition/GC pause; +- quorum acquire response uncertainty; +- 각 master state cleanup; +- protected resource fencing 필요성은 여전히 남음. + +특정 product가 Redlock을 선택하면 별도 ADR, failure model, clock assumptions, quorum topology, +fenced consumer test가 필요하다. template 기본 descriptor는 `STRICT_COORDINATION_REQUIRED`를 +Redis로 충족하지 않는다. + +### 23.5 leader election + +leader election은 lease 위에 semantic contract로 제공한다. + +- epoch/fencing token; +- lease-lost callback; +- leader-only task cancellation; +- takeover delay; +- no singleton business correctness claim; +- scheduler work idempotency. + +bean name `outboxLeaderElection` 존재만으로 안전을 판단하지 않는다. + +### 23.6 semaphore + +bounded distributed semaphore는 owner token별 permit record와 TTL이 필요하다. + +- maximum permits; +- owner-safe release; +- crashed owner expiry; +- renewal; +- list cleanup bound; +- fairness non-guarantee; +- Cluster same-slot; +- exact current count reconciliation. + +large owner set를 한 Lua에서 전부 scan하지 않는다. + +### 23.7 work claim + +queue/job claim은 lock과 다른 contract다. + +```text +claim item -> owner/attempt/lease +ack success +nack retry +reclaim expired +``` + +ordering, retry count, terminal state가 필요하면 Redis Streams나 durable DB queue의 semantic +contract를 사용한다. 단순 lease key로 queue를 만들지 않는다. + +## 24. Redis idempotency design + +### 24.1 guarantee 이름 + +Redis provider의 기본 guarantee는: + +```text +REQUEST_REPLAY +``` + +이다. 다음을 뜻한다. + +- live record가 유지되는 동안 같은 request fingerprint를 식별; +- completed response를 replay; +- concurrent duplicate에 one current owner를 선택; +- owner-safe transition. + +다음을 뜻하지 않는다. + +- JDBC business effect exactly-once; +- external API side effect exactly-once; +- failover에서도 record zero-loss; +- arbitrary long-term dedup. + +### 24.2 v2 claim + +기존 `find -> tryBegin`을 한 atomic operation으로 바꾼다. + +```java +IdempotencyClaimOutcome claim(IdempotencyClaimRequest request); +``` + +outcome: + +```text +ACQUIRED(ownerToken, attempt, processingLeaseUntil) +REPLAYED_ACQUIRE(ownerToken, attempt, processingLeaseUntil) +TAKEN_OVER_CLAIMED(ownerToken, attempt, processingLeaseUntil) +COMPLETED_REPLAY(storedResponse, replayUntil) +IN_PROGRESS(retryAfter, currentAttempt) +RECOVERY_REQUIRED(currentAttempt) +FINGERPRINT_MISMATCH +OWNER_OPERATION_CONFLICT +INDETERMINATE(operationId) +UNAVAILABLE +``` + +claim caller는 최초 Redis send 전에 local `newClaimAttempt(operationId)`로 secure random +owner token을 받고 request와 함께 보관한다. same scope/fingerprint/owner/operation의 duplicate +claim은 counter/attempt를 바꾸지 않고 `REPLAYED_ACQUIRE`로 동일 owner handle을 반환한다. + +### 24.2.1 complete v2 port + +claim만 바꾸고 기존 scope-only mutation을 남기지 않는다. + +```java +public interface IdempotencyStorePortV2 { + IdempotencyClaimAttempt newClaimAttempt(String operationId); + IdempotencyClaimOutcome claim(IdempotencyClaimRequest request); + + IdempotencyStartOutcome markExecutionStarted( + IdempotencyOwner owner, String operationId); + + IdempotencyRenewOutcome renew( + IdempotencyOwner owner, Duration processingLeaseTtl, String operationId); + + IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, + StoredResponse response, + Duration replayTtl, + String operationId); + + IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + String operationId); + + IdempotencyReleaseOutcome releaseBeforeExecution( + IdempotencyOwner owner, String operationId); + + IdempotencyInspection inspect(IdempotencyInspectionRequest request); +} + +public record IdempotencyClaimAttempt(String ownerToken, String operationId) {} + +public record IdempotencyOwner( + IdempotencyScope scope, String ownerToken, long attempt) {} + +public record IdempotencyInspectionRequest( + IdempotencyScope scope, + String requestFingerprint, + IdempotencyClaimAttempt attempt) {} + +public sealed interface VerifiedIdempotencyReconciliationEvidence + permits VerifiedCommittedEvidence, VerifiedNoEffectEvidence { + String receiptDigest(); + String evidenceType(); + String evidenceRevision(); +} + +public sealed interface VerifiedCommittedEvidence + extends VerifiedIdempotencyReconciliationEvidence + permits SourceCommittedEvidence {} + +public sealed interface VerifiedNoEffectEvidence + extends VerifiedIdempotencyReconciliationEvidence + permits SourceNoEffectEvidence {} + +public interface IdempotencyEffectEvidenceVerifier { + CommittedEvidenceVerificationOutcome verifyCommitted( + IdempotencyReconciliationCandidate candidate); + NoEffectEvidenceVerificationOutcome verifyNoEffect( + IdempotencyReconciliationCandidate candidate); +} + +public interface IdempotencyReconciliationPort { + IdempotencyReconcileCommittedOutcome reconcileCommitted( + IdempotencyCommittedReconciliation request); + + IdempotencyReopenOutcome reconcileNoEffectAndReopen( + IdempotencyNoEffectReconciliation request); +} + +public record IdempotencyReconciliationAudit( + String actorDigest, + String reasonCode, + String evidenceReferenceDigest, + Instant requestedAt) {} + +public record IdempotencyCommittedReconciliation( + IdempotencyScope scope, + long expectedAttempt, + long expectedStateRevision, + VerifiedCommittedEvidence evidence, + IdempotencyReconciliationAudit audit, + String auditOperationId, + StoredResponse response, + Duration replayTtl) {} + +public record IdempotencyNoEffectReconciliation( + IdempotencyScope scope, + long expectedAttempt, + long expectedStateRevision, + VerifiedNoEffectEvidence evidence, + IdempotencyReconciliationAudit audit, + String auditOperationId, + IdempotencyClaimAttempt newAttempt, + Duration processingLeaseTtl) {} +``` + +permitted evidence implementation의 constructor/factory는 application reconciliation package +내부이며 verifier 성공 결과만 생성한다. arbitrary controller DTO는 이 sealed value를 구현하거나 +deserialize할 수 없다. committed request는 `VerifiedCommittedEvidence`, reopen request는 +`VerifiedNoEffectEvidence`만 받아 evidence 방향을 type-level로 뒤집을 수 없게 한다. + +typed outcome: + +```text +Start: + STARTED | ALREADY_STARTED_SAME_OPERATION | ABSENT | NOT_OWNER | + NOT_CLAIMED | OPERATION_CONFLICT | INDETERMINATE | UNAVAILABLE +Renew: + RENEWED | ALREADY_RENEWED_SAME_OPERATION | ABSENT | NOT_OWNER | + NOT_IN_PROGRESS | OPERATION_CONFLICT | INDETERMINATE | UNAVAILABLE +Complete: + COMPLETED | ALREADY_COMPLETED_SAME_RESULT | RESPONSE_CONFLICT | + ABSENT | NOT_OWNER | NOT_IN_PROGRESS | OPERATION_CONFLICT | + INDETERMINATE | UNAVAILABLE +Fail: + MARKED_RETRYABLE | MARKED_ABANDONED | ALREADY_MARKED_SAME_OPERATION | + ABSENT | NOT_OWNER | NOT_IN_PROGRESS | OPERATION_CONFLICT | + INDETERMINATE | UNAVAILABLE +Release: + RELEASED_BEFORE_EXECUTION | ALREADY_RELEASED_SAME_OPERATION | + ABSENT | NOT_OWNER | EXECUTION_ALREADY_STARTED | OPERATION_CONFLICT | + INDETERMINATE | UNAVAILABLE +Inspect: + ABSENT | CLAIMED_SAME_OPERATION(owner,lease) | + EXECUTING_SAME_OPERATION(owner,lease) | COMPLETED_REPLAY(response) | + IN_PROGRESS_OTHER | FAILED_RETRYABLE | ABANDONED | + FINGERPRINT_MISMATCH | OPERATION_CONFLICT | UNAVAILABLE +Reconcile committed: + RECONCILED_COMPLETED | ALREADY_RECONCILED_SAME_OPERATION | + EVIDENCE_CONFLICT | STATE_CONFLICT | ABSENT | INDETERMINATE | UNAVAILABLE +Reopen no effect: + REOPENED_CLAIMED(owner,attempt,lease) | ALREADY_REOPENED_SAME_OPERATION | + EVIDENCE_CONFLICT | STATE_CONFLICT | ABSENT | INDETERMINATE | UNAVAILABLE +``` + +같은 transition kind와 `lastTransitionOperationId`의 duplicate는 prior result를 replay한다. +다른 operation ID가 이미 끝난 동일 transition을 바꾸려 하면 conflict다. claim replay는 별도 +`claimOperationId`를 비교하므로 start/renew가 claim recovery 정보를 덮어쓰지 않는다. claim 응답 +유실 뒤 caller는 보관한 request의 fingerprint/attempt로 `inspect`하거나 동일 claim을 반복해 +owner/attempt를 회수한다. +inspect도 unavailable이면 claim은 `INDETERMINATE`이며 새 owner로 즉시 claim하지 않는다. + +### 24.3 request + +```text +scopeDigest +requestFingerprint +claimAttempt(ownerToken, operationId) +processingLeaseTtl +replayTtl +responseCodecId +policyRevision +``` + +scope raw principal/idempotency key는 adapter에 전달하기 전에 canonical digest로 바꿀 수 있다. +application value에는 provider key syntax가 없다. + +### 24.4 record + +```text +recordVersion +state = CLAIMED | EXECUTING | COMPLETED | FAILED_RETRYABLE | ABANDONED +stateRevision +requestFingerprint +ownerToken +attempt +claimOperationId +lastTransitionOperationId? +lastTransitionKind? +lastTransitionResultDigest? +reconciliationEvidenceDigest? +processingLeaseUntil +responseCodecId? +responseVersion? +responseDigest? +responsePayload? +replayUntil? +createdAt +updatedAt +policyRevision +``` + +processing lease와 completed replay TTL은 분리한다. 30초 execution lease 때문에 completed +response가 30초 후 사라지거나, 24시간 replay TTL 때문에 crashed owner가 24시간 request를 +막으면 안 된다. + +### 24.5 state machine + +```mermaid +stateDiagram-v2 + [*] --> CLAIMED: first claim + CLAIMED --> EXECUTING: markExecutionStarted + CLAIMED --> [*]: release before execution + CLAIMED --> CLAIMED: same owner renew + EXECUTING --> EXECUTING: same owner renew + CLAIMED --> CLAIMED: expired pre-execution owner takeover / attempt+1 + EXECUTING --> ABANDONED: expired execution / effect unknown + EXECUTING --> COMPLETED: owner-safe complete + EXECUTING --> FAILED_RETRYABLE: no-effect confirmed + EXECUTING --> ABANDONED: effect unknown/reconciliation + FAILED_RETRYABLE --> CLAIMED: retry claim + ABANDONED --> CLAIMED: explicit reconciliation proves safe retry + ABANDONED --> COMPLETED: committed receipt reconciliation + COMPLETED --> [*]: replay TTL expires + FAILED_RETRYABLE --> [*]: retry record expires + ABANDONED --> [*]: audit TTL expires +``` + +### 24.5.1 executor와 action lifecycle + +기존 `Supplier`와 “모든 RuntimeException에서 discard” contract를 제거한다. + +```java +public interface IdempotentAction { + IdempotentActionOutcome execute(IdempotencyOwner owner); +} + +public sealed interface IdempotentActionOutcome { + record Committed(R result, EffectReceipt receipt) + implements IdempotentActionOutcome {} + record NoEffectConfirmed(SourceFailure failure) + implements IdempotentActionOutcome {} + record EffectUnknown(SourceFailure failure) + implements IdempotentActionOutcome {} + record CancelledBeforeStart() implements IdempotentActionOutcome {} +} +``` + +`IdempotencyExecutorV2` transition: + +1. claim/inspect로 owner handle을 확정한다. +2. action을 호출하기 직전에 `markExecutionStarted`를 실행한다. +3. start 결과가 `STARTED`/`ALREADY_STARTED_SAME_OPERATION`일 때만 action을 호출한다. +4. start가 indeterminate이면 inspect로 `EXECUTING_SAME_OPERATION`을 확인하기 전에는 action을 + 호출하지 않는다. +5. caller cancellation이 successful start보다 먼저면 `releaseBeforeExecution`; start 뒤면 action + outcome/transaction evidence로만 fail/abandon을 결정한다. + +| Action outcome | Store transition | +| --- | --- | +| `Committed` | owner-safe `complete`; completion indeterminate면 result를 exactly-once라고 응답하지 않고 reconcile | +| `NoEffectConfirmed` | `FAILED_RETRYABLE` 또는 owner-safe release policy | +| `CancelledBeforeStart` | `releaseBeforeExecution` | +| `EffectUnknown` | `ABANDONED/INDETERMINATE`, automatic retry 금지 | +| unclassified thrown exception | default `EffectUnknown`, 절대 delete/release하지 않음 | + +`EffectReceipt`는 domain operation ID, committed source revision, downstream idempotency receipt처럼 +실제 effect를 reconcile할 bounded reference다. Redis key/SDK type은 없다. + +`TransactionalIdempotentAction` helper는 `TransactionPort`와 연결한다. + +```text +validation before transaction fails -> NOT_STARTED/NO_EFFECT_CONFIRMED +transaction rolls back and rollback is confirmed -> NO_EFFECT_CONFIRMED +transaction commit returns successfully -> COMMITTED +commit response/connection state unknown -> EFFECT_UNKNOWN +external side effect inside transaction callback -> 별도 provider receipt 없으면 EFFECT_UNKNOWN +``` + +Redis `complete`는 JDBC transaction 안에 넣어 atomic하다고 가장하지 않고 DB commit 뒤 실행한다. +same-store JPA provider가 claim/effect/complete를 한 transaction으로 제공하는 별도 execution profile은 +실제 `TransactionPort` integration test를 통과한 경우만 `SAME_STORE_TRANSACTIONAL`을 광고한다. + +### 24.6 owner-safe complete + +```text +complete(scope, ownerToken, attempt, responseDigest, response, replayTtl) +``` + +program은: + +1. record exists; +2. state is `EXECUTING`; +3. owner token matches; +4. attempt matches; +5. request fingerprint/policy compatible; +6. response size/version valid + +를 모두 확인한 뒤 `COMPLETED`로 바꾼다. + +stale owner는 new owner's record를 complete할 수 없다. + +### 24.7 idempotent complete + +같은 owner/attempt/response digest의 duplicate complete는 `ALREADY_COMPLETED_SAME_RESULT`로 +성공 취급할 수 있다. 다른 digest는 conflict다. + +이 규칙은 complete response loss 후 reconciliation을 돕는다. + +### 24.8 release/fail + +현재처럼 action이 RuntimeException을 던졌다고 무조건 record를 delete하지 않는다. + +- business action이 시작되기 전 확정 실패: owner-safe release 가능; +- side effect가 없음을 application이 증명: `FAILED_RETRYABLE`; +- side effect가 발생했을 수 있음: `INDETERMINATE`, manual/domain reconciliation; +- stale owner: no-op/conflict. + +`discard(scope)` API는 제거한다. + +### 24.9 renew/takeover + +long action은 owner-safe renew를 사용할 수 있다. renewal 실패/unknown이면 application은 더 이상 +single owner라고 가정하지 않는다. + +expired `CLAIMED`는 business action이 시작되지 않았으므로 attempt를 증가시키고 새 owner +token으로 takeover할 수 있다. old owner의 complete/release를 막는다. + +expired `EXECUTING`은 effect가 commit되고 Redis complete만 빠졌을 수 있으므로 자동 takeover하지 +않는다. claim은 `RECOVERY_REQUIRED`를 반환하고 record를 `ABANDONED/effect-unknown`으로 +fail closed한다. domain receipt, DB unique operation row, downstream provider receipt 등으로: + +- effect가 committed임을 확인하면 owner-safe reconciliation complete; +- no effect를 확인하면 explicit `ABANDONED -> CLAIMED` retry transition; +- 어느 쪽도 확인할 수 없으면 manual review/admission closed + +를 선택한다. 단순 lease expiry는 safe retry 증거가 아니다. + +`IdempotencyReconciliationPort`는 ordinary `IdempotencyStorePortV2`와 bean/type을 분리한다. +default template은 public reconciliation endpoint나 default bean을 만들지 않는다. + +product가 명시적으로 추가한 authenticated operator/domain reconciliation use case만: + +1. authorization과 separation-of-duty를 확인; +2. `IdempotencyEffectEvidenceVerifier`로 DB/domain/downstream source-of-truth를 조회; +3. verifier가 성공해 만든 opaque verified evidence를 bounded digest/reference로 변환; +4. actor digest, allowlisted reason, ticket/evidence reference, requestedAt을 durable audit store에 + 먼저 기록; +5. 별도 reconciliation port를 호출 + +한다. app-bootstrap은 이 authorized reconciler에만 reconciliation bean을 주입하고 ordinary +request executor에는 store port만 주입한다. ArchUnit/composition test가 web controller, 일반 +use case, idempotency executor의 reconciliation port dependency를 금지한다. + +Redis program은 receipt가 진실인지 판별하지 못하고 expected attempt/state revision, prior +evidence, audit operation ID의 CAS/replay만 보장한다. Redis audit TTL이 끝나도 필요한 actor/reason/ +evidence trail이 사라지지 않도록 durable audit retention을 별도로 둔다. caller가 보낸 임의 +evidence digest를 verifier 없이 port에 전달하는 경로는 금지한다. + +### 24.10 cross-store crash gap + +```text +Redis claim acquired +JDBC business transaction commits +process crashes +Redis complete not written +lease expires +retry executes business action again +``` + +Redis idempotency만으로 이 gap을 제거할 수 없다. + +필요한 보완: + +- DB unique constraint; +- domain operation ID; +- same-store inbox/idempotency; +- transactional outbox; +- downstream provider idempotency key; +- reconciliation. + +### 24.11 provider profile + +| Provider | 가능한 guarantee | +| --- | --- | +| Redis | low-latency request replay, declared durability/failover | +| JPA same source DB | same-store atomic claim/effect가 실제 한 transaction일 때 강화 가능 | +| external provider key | 해당 provider 범위의 dedup | + +JPA와 Redis가 동일 port contract suite를 실행하더라도 descriptor의 guarantee는 같지 않다. + +### 24.12 response storage + +- maximum encoded bytes; +- sensitive field allowlist; +- no auth token/secret; +- content type/codec/version; +- status/header allowlist; +- digest; +- encryption requirement; +- replay TTL; +- legal/privacy retention. + +large response 전체를 Redis에 넣지 않고 stable result reference를 저장할 수 있다. reference가 +expired/deleted될 때 replay contract를 별도로 정의한다. + +### 24.13 failure policy + +idempotency store unavailable은 fail closed다. request를 그냥 실행하면 duplicate protection을 +조용히 제거하게 된다. + +읽기/claim timeout의 indeterminate 상태를 500 miss나 new claim으로 바꾸지 않는다. caller-facing +error mapping은 retriable 503/409 등 product contract에서 결정한다. + +### 24.14 provider cutover + +`jdbc -> redis`를 rolling deploy 중 단순 config flip하면 old pod와 new pod가 서로 다른 store에서 +같은 scope를 claim할 수 있다. + +선택: + +1. traffic drain 후 atomic cutover; +2. dual-read/single-write migration coordinator; +3. versioned scope namespace와 client epoch; +4. maintenance window. + +dual-write claim은 두 store 사이 atomic하지 않으므로 기본으로 사용하지 않는다. + +### 24.15 existing JPA schema v2 migration + +현재 `idempotency_record`의 v1 field는 scope, request hash, status, response payload/ref, +`created_at`, 단일 `expires_at` 중심이다. owner-safe v2를 위해 Flyway expand migration이 nullable +column을 먼저 추가한다. + +```text +record_version +state_revision +owner_token +attempt +claim_operation_id +last_transition_operation_id +last_transition_kind +last_transition_result_digest +reconciliation_evidence_digest +processing_lease_until +replay_until +policy_revision +response_codec_id +response_codec_version +response_digest +failure_disposition +updated_at +``` + +migration sequence: + +1. **Expand**: nullable column/index/check constraint를 추가하고 v1 code가 계속 읽을 수 있게 유지. +2. **Bridge reader deploy**: completed v1 row와 v2 row를 모두 읽되 아직 v2 claim을 쓰지 않음. +3. **Drain**: idempotent mutation admission을 잠시 닫고 maximum action/lease time을 기다린 뒤 live + `IN_FLIGHT` v1 row가 0임을 query/metric으로 증명. +4. **Backfill completed**: + `record_version=2`, `replay_until=expires_at`, codec/digest를 existing response에서 계산. + completed row에는 fake owner를 만들어내지 않음. +5. **Switch**: 모든 old writer가 drain된 뒤 v2 claim/owner-safe transition을 한 번에 활성화. +6. **Enforce**: new v2 `CLAIMED`/`EXECUTING` row의 + owner/attempt/claim-operation/processing-lease non-null check와 + owner-aware indexes 추가. +7. **Observe**: mismatch, stale-owner, dual reader, reaper 결과를 compatibility window 동안 관측. +8. **Contract**: old `expires_at`/status interpretation과 v1 API 제거는 다음 migration에서 수행. + +rollback: + +- v2 writer switch 전: schema-compatible old application rollback 가능; +- v2 writer switch 후: v1 writer로 rollback 금지, v2-compatible roll-forward/feature disable만 허용; +- emergency rollback이 필요하면 mutation admission을 닫고 v2 live owner를 drain/reconcile한 후 실행. + +schema backfill과 `jdbc -> redis` provider cutover를 같은 release에서 수행하지 않는다. + +## 25. Redis session profile + +### 25.1 auth mode + +security mode는 exclusive다. + +```text +jwt +redis-session +``` + +JWT mode: + +- stateless; +- no Redis session connection/repository/filter; +- bearer-only CSRF policy; +- Redis failure가 authentication에 영향 없음. + +Redis session mode: + +- server-side opaque session; +- multi-pod shared repository; +- session Redis role required; +- repository failure 시 fail closed/re-auth; +- CSRF/cookie/session fixation policy 필수. + +### 25.2 ownership + +Spring Session은 transport/security infrastructure다. + +- Redis leaf: session role connection과 repository provider; +- inbound web: cookie/CSRF/security/session behavior; +- app-bootstrap: exclusive mode composition; +- application-core: Spring Session 타입 없음. + +“모든 사용자 session revoke”가 business use case가 될 때만 framework-free +`SessionRevocationPort`를 application에 추가한다. + +### 25.2.1 adapter-internal storage contract + +Spring repository가 raw template command를 조합하지 않도록 Redis leaf 내부에만 다음 typed +storage contract를 둔다. application/shared/web에는 노출하지 않는다. + +```java +interface VersionedRedisSessionStore { + SessionMutationAttempt newMutationAttempt(); + SessionCreateOutcome create(SessionCreateCommand command); + SessionInspectionOutcome inspect(SessionInspectionCommand command); + SessionSaveOutcome saveIfLive(SessionSaveCommand command); + SessionTouchOutcome touchIfLive(SessionTouchCommand command); + SessionRevokeOutcome tombstoneAndDelete(SessionRevokeCommand command); + SessionRotateOutcome rotate(SessionRotateCommand command); +} + +record SessionMutationAttempt(String operationId) {} +``` + +repository는 Redis send 전에 operation ID, expected/new revision, encoded payload digest를 command에 +freeze하고 response-loss retry/inspect에 동일 값을 사용한다. + +```text +Create: + CREATED | ALREADY_CREATED_SAME_OPERATION | EXISTS_CONFLICT | + TOMBSTONED | ABSOLUTE_EXPIRED | INDETERMINATE | UNAVAILABLE +Inspect: + LIVE_SAME_MUTATION | LIVE_OTHER | TOMBSTONED_SAME_OPERATION | + TOMBSTONED_OTHER | ABSENT | ABSOLUTE_EXPIRED | UNAVAILABLE +Save: + SAVED | ALREADY_SAVED_SAME_OPERATION | ABSENT | STALE_REVISION | + MUTATION_CONFLICT | TOMBSTONED | ABSOLUTE_EXPIRED | + INDETERMINATE | UNAVAILABLE +Touch: + TOUCHED | ALREADY_TOUCHED_SAME_OPERATION | TOUCH_NOT_DUE | + ABSENT | STALE_REVISION | MUTATION_CONFLICT | TOMBSTONED | + ABSOLUTE_EXPIRED | INDETERMINATE | UNAVAILABLE +Revoke: + REVOKED_AND_DELETED | TOMBSTONED_ABSENT | + ALREADY_REVOKED_SAME_OPERATION | STALE_REVISION | + OPERATION_CONFLICT | INDETERMINATE | UNAVAILABLE +Rotate: + ROTATED | ALREADY_ROTATED_SAME_OPERATION | OLD_ABSENT | + STALE_REVISION | OLD_TOMBSTONED | NEW_ID_CONFLICT | + ABSOLUTE_EXPIRED | INDETERMINATE | UNAVAILABLE +``` + +create/save response를 잃으면 같은 command 반복이 stored last-mutation operation ID와 payload +digest/revision을 비교해 `ALREADY_*_SAME_OPERATION`을 반환한다. inspect에서 same mutation이 +확인되어도 payload digest와 resulting revision이 모두 일치해야 applied로 reconcile한다. +`LIVE_OTHER`, stale revision, 다른 tombstone은 blind overwrite/delete가 아니라 conflict다. + +revoke response-loss는 tombstone operation ID를 inspect하고, rotate는 old tombstone과 new live +session의 operation/revision을 함께 확인한다. 둘 중 하나만 보이면 fail closed + reconciliation +대상이다. arbitrary retry에서 새 operation/session ID를 만들지 않는다. + +### 25.3 repository choice + +baseline은 index가 필요 없는 repository를 선택한다. + +```text +RedisSessionRepository +``` + +이 baseline은 principal indexing, session-destroyed event, logout-all, concurrent-session-control이 +필요 없는 profile로 제한한다. + +principal lookup, concurrent-session control, logout-all index가 실제 필요할 때만: + +```text +RedisIndexedSessionRepository +``` + +를 선택한다. + +`RedisIndexedSessionRepository + Redis Cluster`는 stock 구현만으로 principal index cleanup, +logout-all, concurrent-session-control guarantee를 제공하지 않는다. 임의 한 node의 keyspace +event만 구독해 다른 shard event를 놓칠 수 있기 때문이다. + +이 guarantee가 필요하면: + +1. non-Cluster dedicated session deployment를 사용하거나; +2. 모든 primary event 구독, topology-change 재구독, durable reconciliation/reaper를 구현한 별도 + indexed provider + +중 하나를 선택한다. 단순 topology test 한 번으로 guarantee를 승격하지 않는다. + +### 25.4 session value + +session에는 최소 정보만 둔다. + +- authentication/session metadata; +- CSRF token; +- bounded allowlisted attributes; +- created/last-access/absolute-expiry; +- security revision. + +large business aggregate, arbitrary request object, persistence entity를 저장하지 않는다. + +### 25.5 serializer + +default JDK serialization을 사용하지 않는다. + +- explicit serializer bean; +- allowlisted type set; +- schema/version envelope; +- no unrestricted polymorphic typing; +- N/N-1 rolling compatibility; +- maximum bytes/depth/collection elements; +- corrupt session은 invented auth가 아니라 invalidate + re-auth; +- security context library version upgrade test. + +### 25.6 cookie + +production setting: + +- CSPRNG opaque session ID; +- `Secure`; +- `HttpOnly`; +- appropriate `SameSite`; +- bounded path/domain; +- no session ID in URL; +- proxy/TLS termination awareness; +- cookie name/environment collision 방지; +- rotation during privilege change. + +cookie secret/value를 log하지 않는다. + +### 25.7 CSRF + +cookie-based authentication은 browser가 credential을 자동 전송하므로 CSRF protection이 필요하다. + +- state-changing method protection; +- token storage/transport; +- CORS와 credential setting; +- logout CSRF; +- multi-tab behavior; +- error mapping + +을 web contract test로 검증한다. JWT bearer-only mode의 기존 CSRF disable과 섞지 않는다. + +### 25.8 fixation과 rotation + +login, privilege elevation, sensitive re-authentication 후 session ID를 rotate한다. old ID는 +더 이상 valid하지 않아야 한다. + +rotation 중 attributes/TTL copy와 old key delete의 crash path를 test한다. two active IDs가 잠깐 +허용되는지, old ID를 즉시 deny하는지 contract를 명시한다. + +### 25.9 idle와 absolute expiry + +두 경계를 분리한다. + +```text +idle timeout +absolute lifetime +``` + +매 access가 idle TTL을 touch해도 absolute lifetime을 넘지 않는다. Redis TTL은 physical cleanup, +session metadata는 logical expiry를 확인한다. + +stock Spring Session의 idle `maxInactiveInterval`만으로 absolute lifetime이나 logout tombstone을 +제공한다고 가정하지 않는다. `VersionedRedisSessionRepository` decorator/custom repository가: + +- `findById` 후 SecurityContext 사용 전에 `absoluteExpiresAt`과 revocation marker 확인; +- 위반 시 fail closed + delete; +- save/touch TTL을 `min(idleTimeout, absoluteExpiresAt - serverNow)`로 제한; +- session revision/tombstone CAS; +- serializer envelope + +를 소유한다. 이 custom path가 비활성이라면 descriptor에서 absolute lifetime, atomic live touch, +no-resurrection guarantee를 제거한다. + +R2 custom path는 §13.10의 `session-create`, `session-save-if-live`, +`session-touch-if-live`, `session-tombstone-and-delete`, `session-rotate` v1 manifest를 사용한다. +stock repository의 plain save/delete를 이 guarantee의 대체로 인정하지 않는다. + +### 25.10 touch + +매 request full session write는 write amplification을 만든다. + +- changed attribute save; +- bounded touch interval; +- atomic live-check + TTL update; +- absolute expiry guard; +- touch failure policy; +- concurrent logout race + +를 다룬다. + +touch throttling은 configured idle timeout보다 충분히 짧아야 한다. 정확한 값은 SLO/traffic로 +결정한다. + +baseline Spring Session modes는: + +```text +FlushMode.ON_SAVE +SaveMode.ON_SET_ATTRIBUTE +``` + +로 고정하고 write amplification/concurrent overwrite contract test를 둔다. 다른 mode는 별도 +capability revision이다. + +### 25.11 logout resurrection + +다음 race를 test한다. + +```text +Request A reads session +Request B logs out and deletes session +Request A finishes and saves stale session +``` + +logout은 tombstone/revision을 owner-safe atomic program으로 먼저 기록한 뒤 session을 삭제한다. +stale request의 save/touch는 tombstone/revision CAS에서 거절한다. tombstone TTL은 가능한 stale +request 최대 수명과 shutdown/drain budget보다 길고 bounded하다. + +### 25.12 concurrent mutation + +Redis session은 일반적으로 application-level serializable transaction을 제공하지 않는다. +동시 request의 attribute write는 last-write-wins/merge conflict가 날 수 있다. + +- mutable business state를 session에 저장하지 않음; +- security-sensitive attribute에 revision/CAS; +- concurrent request contract test; +- lost update가 허용되는 attribute만 일반 save. + +### 25.13 expiry event + +keyspace expiry notification은 cleanup optimization이다. exact expiry trigger나 logout audit +source가 아니다. logical expiry는 read 시 검증하고 orphan index는 reaper가 reconcile한다. + +### 25.14 failure + +session Redis unavailable: + +- existing auth를 invented/anonymous success로 바꾸지 않음; +- protected endpoint fail closed; +- user-facing re-auth/retry behavior; +- readiness degraded; +- bounded error storm logging; +- recovered connection에서 stale session resurrection 방지. + +### 25.15 backup/restore + +old session snapshot을 restore하면 revoked/expired session이 되살아날 수 있다. + +- session key epoch; +- security revision; +- restore 후 global invalidation option; +- incident runbook; +- backup retention/privacy + +가 필요하다. session backup이 항상 유용하다고 가정하지 않는다. + +## 26. Pub/Sub, keyspace notification, Streams 경계 + +### 26.1 Pub/Sub 허용 범위 + +허용: + +- L1 cache invalidation hint; +- live UI refresh hint; +- loss-tolerant internal signal. + +금지: + +- business event authoritative delivery; +- outbox replacement; +- payment/notification job; +- session revoke의 유일한 전달; +- exact cache consistency. + +Pub/Sub은 at-most-once이며 disconnect 중 message를 replay하지 않는다. + +### 26.2 keyspace notification + +notification은: + +- 기본 disabled일 수 있음; +- server CPU overhead; +- Cluster node-specific subscription; +- expiry 발생 시각 지연; +- disconnect loss + +가 있다. session index cleanup이나 diagnostics 보조에만 사용한다. + +### 26.3 client-side caching/tracking + +Redis client-side tracking으로 L1 invalidation을 받을 수 있으나: + +- client/library/topology compatibility; +- invalidation connection lifecycle; +- disconnect 시 local cache flush; +- failover/reconnect; +- redirect/Cluster; +- maximum tracked prefixes + +를 검증한다. baseline off다. + +### 26.4 Streams ownership + +Redis Streams producer/consumer가 application messaging capability가 되면 existing cache leaf의 +internal helper가 아니라 messaging semantic port 구현이어야 한다. + +- outbound producer: outbound messaging provider; +- consumer group listener: future inbound messaging adapter; +- shared connection/runtime는 추출 가능한 internal library 또는 duplicated narrow config; +- cache Redis failure policy 재사용 금지. + +### 26.5 Streams guarantee + +consumer group은 pending entry와 ACK로 redelivery를 제공할 수 있지만: + +- ack 전 crash -> duplicate; +- async replication/persistence loss; +- trim과 PEL interaction; +- poison message; +- consumer reclaim; +- single stream hot key; +- no DB+XADD transaction + +이 남는다. + +end-to-end는 at-least-once + idempotent consumer/inbox로 표현한다. producer dedup feature가 있어도 +consumer side effect exactly-once를 뜻하지 않는다. + +### 26.6 DB dual write + +```text +DB commit +XADD +``` + +사이 crash gap은 Redis가 해결하지 않는다. DB가 source of truth이면 transactional outbox/CDC가 +우선이다. + +## 27. Topology design + +### 27.1 exclusive topology + +deployment 하나는 정확히 하나를 선택한다. + +```text +standalone +sentinel +cluster +``` + +host list가 비어 있거나 두 topology field가 동시에 설정되면 startup failure다. + +### 27.2 standalone + +용도: + +- local development; +- CI focused integration; +- product가 external HA를 제공하는 managed endpoint. + +single process Redis를 production HA라고 부르지 않는다. managed proxy endpoint 뒤 topology는 +operator attestation과 provider documentation으로 descriptor에 기록한다. + +### 27.3 Sentinel + +요구: + +- master name; +- independent Sentinel endpoints; +- Redis data-node credentials와 Sentinel credentials 분리; +- TLS; +- failover timeout; +- client master rediscovery; +- old master partition test; +- topology event telemetry. + +Sentinel은 failover를 자동화하지만 replication은 eventual/asynchronous다. partition된 old master에 +acknowledged write가 합류 후 사라질 수 있다. + +Sentinel 자체도 quorum/majority가 필요하다. 같은 node/failure zone에 Sentinel을 몰아놓고 HA라고 +표현하지 않는다. + +### 27.4 Cluster + +Redis Cluster: + +- 16,384 hash slots; +- database 0; +- node redirect (`MOVED`, `ASK`); +- multi-key/transaction/program same-slot requirement; +- topology refresh; +- uncovered slot/cluster state; +- primary/replica mapping + +을 client가 이해해야 한다. + +### 27.5 Cluster key validation + +build/unit: + +- key builder hash tag invariant; +- every program descriptor key count/slot rule. + +integration: + +- different slot multi-key가 expected failure; +- same tag 성공; +- reshard during traffic; +- `MOVED`/`ASK`; +- new/unknown node; +- failover 후 program availability. + +### 27.6 topology refresh + +Lettuce Cluster는 periodic + adaptive refresh를 명시적으로 설정한다. + +관측: + +- refresh count/reason; +- `MOVED`/`ASK`; +- persistent reconnect; +- unknown node; +- refresh failure; +- topology age. + +managed/Kubernetes/NAT 환경에서 Redis가 advertise한 node address가 application pod에서 reachable한지 +deployment conformance test로 확인한다. + +### 27.7 read routing + +기본: + +- coordination/session/idempotency/lease/rate: primary only; +- cache: primary by default; +- stale-tolerant cache region만 replica read opt-in. + +replica read는 latency/scale option이지 read-your-write를 보장하지 않는다. invalidation 직후 old +replica value를 읽을 수 있음을 region descriptor에 명시한다. + +### 27.8 multi-region + +active-active/multi-region Redis는 이 R2 baseline 밖이다. + +검토해야 할 것: + +- conflict resolution; +- local/global quota; +- session home region; +- idempotency scope; +- fencing token order; +- WAN partition; +- replication lag; +- data residency. + +단일 region 설계를 DNS global endpoint로 바꿨다고 multi-region correctness가 되지 않는다. + +## 28. Replication, persistence, failover guarantee + +### 28.1 asynchronous replication + +Redis replication은 기본적으로 asynchronous다. primary가 write ACK 후 replica 전파 전에 죽으면 +promoted replica에 write가 없을 수 있다. + +영향: + +- completed idempotency record loss; +- session loss; +- rate token rollback; +- lease/fence record loss; +- duplicate lock holder; +- cache cold/stale. + +각 capability descriptor가 이 결과를 명시한다. + +### 28.2 `WAIT` + +`WAIT`는 지정 replica가 write를 받은 acknowledgement를 기다려 loss probability를 줄일 수 있다. +그러나 strong consistency나 CP를 만들지 않는다. + +- 대상 write/`FCALL`/`EVALSHA`와 정확히 같은 physical connection, 같은 primary에서 write 응답 + 직후 실행; +- Cluster에서는 target key slot을 소유한 primary connection을 명시적으로 고정; +- 별도 Spring template 호출 두 번으로 connection affinity를 추정하지 않고 dedicated + `RedisConnection`/native connection callback 사용; +- 특정 replica identity가 아니라 acknowledgement 개수만 요청; +- Lua/Function 또는 `MULTI/EXEC` 내부의 blocking acknowledgement로 사용 금지; +- production `timeout=0` 금지; +- 요구 수보다 작은 반환은 timeout/degraded; +- timeout에도 실제 replica 수가 일부 ACK했을 수 있음; +- failover selection/partition; +- write + WAIT 전체의 client response loss; +- throughput/latency cost. + +strict profile의 optional acknowledgement 강화로만 표현한다. + +### 28.3 `WAITAOF` + +supported Redis version에서 `WAITAOF`는 local/replica AOF fsync acknowledgement를 강화할 수 있다. +역시 cross-store atomicity, failover selection, zero-loss를 보장하지 않는다. + +`WAITAOF`도 대상 write와 같은 physical connection/primary에서 바로 호출하며 Function/Lua나 +`MULTI/EXEC` 안에 넣지 않는다. local AOF acknowledgement를 요구하는 profile은 selected primary에 +AOF가 실제 enabled라는 external attestation을 먼저 검증한다. + +사용 여부는 capability descriptor에: + +```text +replica acknowledgements +local AOF acknowledgements +timeout behavior +achieved acknowledgement count +``` + +로 기록한다. + +write와 acknowledgement command 사이의 process/connection crash gap, acknowledgement response +loss는 여전히 `INDETERMINATE`다. + +### 28.4 RDB + +RDB snapshot: + +- compact backup/startup; +- snapshot 간 write loss 가능; +- fork/COW memory와 latency; +- snapshot failure monitoring. + +recomputable cache에 적합할 수 있다. session/idempotency RPO는 snapshot interval만 보고 +“durable”이라고 표현하지 않는다. + +### 28.5 AOF + +AOF: + +- fsync policy별 loss/latency trade-off; +- rewrite; +- disk space; +- corruption/recovery; +- fork/COW; +- write error. + +`everysec`는 일반적으로 최근 구간 loss 가능성이 있다. exact maximum loss를 환경 검증 없이 +단정하지 않는다. + +### 28.6 no persistence + +cache role은 no-persistence를 선택할 수 있다. + +조건: + +- 모든 값 재생성 가능; +- cold-start source capacity; +- startup warm strategy; +- cache loss alert severity; +- session/idempotency co-location 없음. + +### 28.7 role별 baseline + +| Role | Persistence | Replication | Effective claim | +| --- | --- | --- | --- | +| cache | optional | optional/replica read | recomputable, loss acceptable | +| coordination | explicit AOF/RPO | primary + replicas | low-latency state, loss still possible | +| session | product RPO에 맞는 AOF/HA | primary + replicas | session continuity best effort, re-auth recovery | + +“AOF + replica = never lose”는 금지 문구다. + +### 28.8 failover result + +failover 중 client operation은: + +```text +known not sent +sent and rejected +applied but response lost +applied on old primary then lost +replayed on new primary +``` + +중 하나일 수 있다. client error class 하나만으로 정확히 구분되지 않을 수 있으므로 +capability-specific operation token과 reconciliation이 필요하다. + +### 28.9 restore + +backup restore 후: + +- key schema/program version; +- expired logical record; +- session security epoch; +- idempotency replay horizon; +- fencing high watermark; +- rate policy revision; +- orphan namespace + +를 reconcile한다. raw restore 성공이 application consistency 완료를 뜻하지 않는다. + +## 29. Memory, eviction, big key, hot key + +### 29.1 memory budget + +Redis `maxmemory`를 container/node memory limit과 같게 두지 않는다. + +별도 headroom이 필요한 항목: + +- allocator fragmentation; +- replication backlog/buffer; +- AOF buffer/rewrite; +- fork copy-on-write; +- client input/output buffer; +- script/function memory; +- OS/page cache; +- TLS/client overhead. + +일부 replication/AOF buffer는 eviction 비교에서 제외될 수 있다. `mem_not_counted_for_evict`를 +포함해 effective headroom을 관측한다. + +### 29.2 capacity input + +region/capability마다 계산 input을 문서화한다. + +```text +estimated key cardinality +average/p95/max key bytes +average/p95/max value bytes +Redis object/allocator overhead +TTL distribution +write/read rate +replication factor +growth rate +headroom factor +``` + +가짜 “몇 GB면 충분” 값을 skeleton에 넣지 않는다. 대신 startup/config는 maximum payload, +cardinality budget, queue bound처럼 안전에 필요한 upper bound를 요구한다. + +### 29.3 eviction policy + +cache role: + +- `allkeys-lfu`: reusable hot value 유지에 일반적으로 적합; +- `allkeys-lru`: recency가 workload와 더 맞을 때; +- volatile policy: TTL 누락 key가 eviction 대상에서 빠질 위험을 이해한 경우만; +- `noeviction`: cache write OOM이 source load storm을 만들 수 있어 별도 설계 필요. + +coordination/session role: + +- `noeviction`; +- write OOM을 explicit failure로 받아들임; +- capacity alert와 scale/runbook; +- correctness record가 arbitrary eviction되지 않음. + +policy 이름만 검사하지 않고 실제 role/data와 맞는지 검증한다. + +### 29.4 OOM semantics + +`noeviction`에서 memory limit을 넘는 write는 실패할 수 있다. 기존 read가 된다고 capability가 +healthy한 것은 아니다. + +- session create/touch OOM -> fail closed/readiness down; +- idempotency claim/complete OOM -> fail closed/indeterminate; +- rate-limit mutation OOM -> policy failure mode; +- lease acquire OOM -> no acquire; +- cache put OOM -> source response는 가능하지만 degraded. + +Lua/Function도 low-memory에서 첫 write와 후속 write의 behavior를 real Redis로 test한다. + +### 29.5 big key + +big key는: + +- network/event-loop latency; +- serialization allocation; +- replication; +- persistence; +- delete latency; +- failover/recovery + +를 악화시킨다. + +방어: + +- encoded/decoded maximum; +- collection element maximum; +- bounded batch; +- compression upper bound; +- `UNLINK` maintenance; +- CI big-key negative test; +- operator `--keystats`/sampling. + +### 29.6 hot key + +hot key는 memory가 작아도 single shard CPU/network를 포화시킨다. + +예: + +- global rate counter; +- one popular cache object; +- one tenant hash tag; +- global session index; +- single Redis Stream. + +metric에 raw key를 넣지 않고 bounded sampled key fingerprint/operator tool로 찾는다. + +### 29.7 dangerous collection operations + +regular runtime에서 금지: + +```text +KEYS +unbounded HGETALL +unbounded SMEMBERS +unbounded LRANGE 0 -1 +unbounded ZRANGE +unbounded XREAD without COUNT/block deadline +``` + +모든 collection operation은 maximum result count와 byte budget을 갖는다. + +### 29.8 `SCAN` + +`SCAN`도 free가 아니다. + +- maintenance/admin path만; +- bounded COUNT hint; +- rate limit; +- cancellation/deadline; +- duplicate/missing observation 허용; +- mutation 중 exact snapshot 아님; +- Cluster node별 scan; +- report-only default; +- `UNLINK` batch와 backpressure. + +request handler에서 wildcard invalidation에 사용하지 않는다. + +### 29.9 fragmentation + +`used_memory`, RSS, allocator fragmentation ratio를 함께 본다. fragmentation threshold를 +universal constant로 고정하지 않고 version/workload baseline과 추세로 alert한다. + +active defrag/allocator/server tuning은 deployment policy다. application이 바꾸지 않는다. + +### 29.10 cache stampede under eviction + +eviction이 급증하면 hit ratio 하락 -> source load -> cache refill -> eviction의 feedback loop가 +생긴다. + +관측/대응: + +- evicted keys rate; +- hit/miss trend; +- source fallback concurrency; +- cache write rejected; +- hot/big key; +- admission; +- lower TTL가 아니라 memory/cardinality root cause; +- stale serve/load shedding. + +## 30. Time와 expiration + +### 30.1 clock ownership + +capability별 clock: + +| Capability | Enforcement clock | +| --- | --- | +| cache physical TTL | Redis | +| cache envelope freshness | Redis write time + application observation | +| rate limit | Redis `TIME` | +| lease validity | Redis TTL + client monotonic elapsed budget | +| idempotency processing/replay | Redis server time | +| session idle TTL | repository/Redis | +| session absolute lifetime | stored metadata + server/client validation | + +pod wall-clock만으로 shared quota/lease를 계산하지 않는다. + +### 30.2 absolute expiration + +Redis는 expiry를 absolute wall-clock timestamp로 다룬다. server clock jump가 대량 즉시 expiry나 +수명 연장을 만들 수 있다. + +- NTP/clock monitoring; +- large clock step alert; +- rate/lease tests with time movement; +- `resetAt`는 server time에서 계산; +- client monotonic clock은 local deadline duration에 사용. + +### 30.3 active/passive expiration + +expired key는 access 시 passive하게, background sampling으로 active하게 제거된다. keyspace +notification 시각은 logical TTL boundary와 동일하지 않을 수 있다. + +application은: + +- read result/TTL로 logical expiry 확인; +- expiry event를 correctness trigger로 사용하지 않음; +- memory가 즉시 회수된다고 가정하지 않음. + +### 30.4 TTL sentinel + +expirable capability key에서: + +```text +TTL = -1 -> corruption/policy violation +TTL = -2 -> absent +``` + +로 구분한다. `-1`을 immortal success로 그대로 두지 않고 capability별 repair/quarantine과 alert를 +수행한다. + +### 30.5 jitter와 legal/security expiry + +cache freshness에는 positive/negative jitter를 적용할 수 있다. session absolute expiry, +credential revocation, compliance deadline에는 positive jitter로 수명을 늘리지 않는다. + +### 30.6 long duration bound + +TTL을 millisecond integer로 변환할 때: + +- overflow; +- zero truncation; +- negative; +- provider maximum; +- policy maximum + +을 validation한다. `Duration`을 `int` millisecond로 축소하지 않는다. + +### 30.7 expiry race + +read 직후 TTL이 만료될 수 있다. lease/session/idempotency는 “GET 성공했으므로 앞으로 TTL 동안 +valid”라고 추정하지 않는다. + +- owner-safe program에서 value와 TTL을 같이 확인; +- lease handle remaining validity; +- session logical expiry; +- idempotency state transition server-side time. + +## 31. Client와 connection runtime + +### 31.1 client selection + +기본 provider는 Spring Data Redis + Lettuce다. + +선정 이유: + +- Spring Boot/Spring Data integration; +- standalone, Sentinel, Cluster; +- sync/async/reactive API; +- thread-safe shared native connection support; +- Spring Session과 connection factory integration; +- topology refresh와 reconnect telemetry 접근. + +이는 application에 Spring Data abstraction을 노출한다는 뜻이 아니다. + +### 31.2 alternatives + +Jedis: + +- blocking model과 explicit pool이 단순한 workload에 적합할 수 있음; +- 같은 semantic contract를 구현하는 별도 provider 후보; +- default로 두 client를 동시에 만들지 않음. + +Redisson: + +- high-level distributed object 제공; +- 별도 lifecycle/semantics/dependency cost; +- port contract 뒤의 optional provider. + +### 31.3 dependency 직접 소유 + +Redis leaf는 broad starter에 기대지 않고 필요한 dependency를 직접 선언한다. + +```text +spring-data-redis +lettuce-core +spring-boot-autoconfigure +micrometer-core (실제 instrumentation 소유 시) +``` + +Spring Session dependency ownership은 다음으로 고정한다. + +| Module | Direct dependency | Responsibility | +| --- | --- | --- | +| `:adapter:outbound:cache-redis` | `spring-session-core`, `spring-session-data-redis` | repository, Redis storage, versioned serializer/decorator; Servlet type 금지 | +| `:adapter:inbound:web` | 사용 type이 있을 때 `spring-session-core` | cookie/CSRF/security integration; Redis leaf/package 의존 금지 | +| `:app-bootstrap` | composition type을 compile할 때 `spring-session-core` | exclusive auth-mode composition과 qualified bean wiring | + +Spring Security/web dependency는 inbound web이 직접 소유한다. app-bootstrap과 inbound web이 +Spring Session type을 compile하지 않으면 해당 `spring-session-core` dependency도 추가하지 +않는다. 어떤 경우에도 Redis leaf의 transitive `implementation` leakage에 기대지 않는다. + +Boot의 Redis/Session auto-configuration은 배제하거나 조건을 좁혀, unqualified global +`RedisConnectionFactory`, default session repository, classpath 기반 repository activation을 +만들지 못하게 한다. role-qualified factory와 explicit repository configuration만 허용한다. +composition contract test는 cache/session factory 오주입, duplicate repository, JWT mode의 +Session bean/connection 생성을 실패시킨다. + +version은 Spring Boot BOM을 사용하되 lockfile로 고정한다. + +### 31.4 one runtime per deployment + +deployment ID마다: + +- `RedisClient`; +- client resources/event loop; +- connection factory; +- topology settings; +- credential/TLS material; +- metrics scope; +- lifecycle + +를 명확히 소유한다. + +같은 endpoint/credential/topology인 role은 policy가 compatible할 때만 runtime을 공유한다. + +### 31.5 connection types + +일반 non-blocking command: + +- thread-safe shared native connection 사용 가능; +- connection 수보다 in-flight/queue bound가 중요. + +다음은 전용 connection/pool이 필요할 수 있다. + +- blocking `XREAD`; +- Pub/Sub; +- transaction with connection affinity; +- long-running maintenance; +- stateful command mode. + +blocking operation이 일반 cache/rate connection을 점유하지 않는다. + +### 31.6 timeouts + +최소 분리: + +```text +DNS/connect timeout +TLS handshake timeout +pool acquire timeout +command timeout +capability overall deadline +blocking command timeout +shutdown timeout +``` + +하나의 global timeout으로 합치지 않는다. + +관계: + +```text +connect/command/acquire 각각 finite +capability overall deadline <= caller deadline +lease wait + work budget < caller deadline +blocking timeout < connection lifecycle timeout +``` + +exact default는 workload SLO로 조정하지만 production config는 finite upper bound를 요구한다. + +### 31.7 request queue + +reconnect 중 command를 무제한 buffer하면 Redis outage가 application heap outage로 바뀐다. + +- disconnected request queue bounded; +- max in-flight bounded; +- capability별 bulkhead; +- queue full -> `OVERLOADED_BEFORE_SEND`; +- heap-based bound가 아니라 command/byte estimate 고려; +- queue depth/oldest age metric; +- required coordination은 빠르게 reject. + +Cluster client의 theoretical queue upper bound는 Lettuce의 connection fan-out을 포함해 최소: + +```text +requestQueueSize * ((clusterNodeCount * 2) + 1) +``` + +을 capacity input으로 사용하고, 실제 connection/runtime/bulkhead 수를 곱해 heap budget을 +검증한다. 이 식은 memory 예약량의 충분조건이 아니며 command payload/response bytes도 더한다. + +### 31.8 reconnect와 replay + +Lettuce는 reconnect와 pending command replay behavior를 가진다. non-idempotent mutation이 다시 +전송되면 duplicate effect가 날 수 있다. + +baseline mutation runtime은 BOM이 선택한 Lettuce API의 semantics를 compatibility test로 확인한 +뒤 connection 생성 전에 다음을 고정한다. + +```text +autoReconnect = true +replayFilter(command -> true) // true == replay 대상에서 제외 +disconnectedBehavior = REJECT_COMMANDS +requestQueueSize = finite bound +``` + +즉 driver-level pending replay는 모두 억제한다. retry-safe `GET` 등도 reconnect 후 capability +wrapper가 새 invocation으로 total deadline 안에서 재시도한다. 선택적 replay가 필요하면 raw command +type만 보지 않고 `FCALL/EVALSHA` program identity를 구분하거나 replay policy별 client/connection을 +분리한다. + +operation을 분류한다. + +| Operation | retry/replay | +| --- | --- | +| GET/TTL | deadline 안의 bounded retry 가능 | +| idempotent delete desired-absent | bounded retry 가능 | +| set same value/version | 조건부 가능 | +| INCR/token consume | operation dedup 없으면 자동 replay 금지 | +| idempotency claim | same operation/owner token으로 reconcile | +| lease acquire | same owner token inspect | +| XADD/PUBLISH | messaging-specific dedup/at-least-once contract | + +client global auto-replay만 믿지 않고 capability wrapper가 certainty를 분류한다. + +startup descriptor와 reconnect integration test는 effective client options가 Lettuce default와 +다름을 검증한다. 사용 중인 Lettuce version에 `replayFilter` semantics가 다르거나 없으면 release를 +막고 별도 no-replay connection strategy를 구현한다. + +### 31.9 cancellation + +caller timeout으로 future를 cancel해도 server command가 이미 실행되었을 수 있다. + +- local wait cancellation과 server execution 결과를 구분; +- mutation은 `INDETERMINATE`; +- connection을 무조건 close해 다른 multiplexed command를 해치지 않음; +- operation token reconciliation. + +### 31.10 backoff + +retry는: + +- total deadline 안; +- exponential bounded backoff; +- jitter; +- maximum attempts; +- only retry-safe error/operation; +- circuit/topology state awareness + +를 따른다. Redis timeout에 모든 request가 같은 즉시 retry를 하지 않는다. + +### 31.11 circuit breaker + +generic circuit breaker를 모든 Redis operation에 동일 적용하지 않는다. + +- cache read: open 시 source fallback; +- strict rate/idempotency/session: open 시 fail closed; +- lease: no acquire; +- health probe가 breaker를 계속 열지 않게 분리; +- half-open traffic bounded; +- reconnect/topology failure와 중복 폭증 방지. + +bulkhead/queue bound가 우선이고 breaker는 장애 전파 제어 수단이다. + +### 31.12 event loop + +Lettuce/Netty event loop에서: + +- blocking DB call; +- heavy JSON encode/decode; +- compression; +- business logic; +- synchronous wait + +를 실행하지 않는다. sync adapter도 underlying event loop와 caller thread 책임을 명확히 한다. + +### 31.13 virtual threads + +Java 21 virtual thread를 사용해도 Redis server/event-loop/connection queue capacity가 늘어나는 것은 +아니다. 더 많은 concurrent caller가 queue를 포화시킬 수 있으므로 in-flight semaphore와 deadline은 +그대로 필요하다. + +### 31.14 DNS와 endpoint + +- startup DNS validation; +- TTL/re-resolution; +- managed failover endpoint; +- IPv4/IPv6; +- certificate SAN; +- Cluster advertised address; +- Kubernetes service/NAT + +를 topology profile별로 test한다. resolved IP를 영구 cache하는 custom code를 만들지 않는다. + +### 31.15 client name + +bounded client name: + +```text +app + env + role + instance-short-id +``` + +raw hostname/user data를 넣지 않는다. operator가 `CLIENT LIST`/managed metrics에서 workload를 +식별할 수 있게 한다. + +## 32. Configuration design + +### 32.1 top-level shape + +상위 capability platform 설계와 같은 prefix를 사용한다. provider 정의는 연결 후보를 +등록할 뿐 activation하지 않으며, `capabilities`의 provider/mode/binding 선택만 activation +SSOT다. + +```yaml +ca-skeleton: + providers: + redis: + deployments: + cache-main: + topology: standalone + standalone: + endpoints: + - host: redis-cache.internal + port: 6379 + database: 0 + client-name: worklog-cache + authentication: + username: cache-runtime + password-ref: secret://redis/cache/password + tls: + enabled: true + verify-hostname: true + trust-bundle-ref: secret://redis/cache/ca + timeout: + connect: 1s + command: 250ms + acquire: 100ms + shutdown: 5s + queue: + max-in-flight: 1024 + disconnected-behavior: reject + max-buffered-disconnected-requests: 0 + topology-refresh: + periodic: 30s + adaptive: true + read: + preference: primary + + coordination-main: + topology: sentinel + sentinel: + master-name: ca-coordination + endpoints: + - host: sentinel-a.internal + port: 26379 + - host: sentinel-b.internal + port: 26379 + - host: sentinel-c.internal + port: 26379 + authentication: + username: coordination-sentinel-discovery + password-ref: secret://redis/coordination/sentinel-password + tls: + enabled: true + verify-hostname: true + trust-bundle-ref: secret://redis/coordination/sentinel-ca + authentication: + username: coordination-runtime + password-ref: secret://redis/coordination/password + tls: + enabled: true + verify-hostname: true + trust-bundle-ref: secret://redis/coordination/data-ca + timeout: + connect: 1s + command: 500ms + acquire: 100ms + shutdown: 5s + + session-main: + topology: sentinel + sentinel: + master-name: ca-session + endpoints: + - host: session-sentinel-a.internal + port: 26379 + - host: session-sentinel-b.internal + port: 26379 + - host: session-sentinel-c.internal + port: 26379 + authentication: + username: session-sentinel-discovery + password-ref: secret://redis/session/sentinel-password + tls: + enabled: true + verify-hostname: true + trust-bundle-ref: secret://redis/session/sentinel-ca + authentication: + username: session-runtime + password-ref: secret://redis/session/password + tls: + enabled: true + verify-hostname: true + trust-bundle-ref: secret://redis/session/data-ca + + roles: + cache: + deployment: cache-main + required: false + expected-eviction: allkeys-lfu + coordination: + deployment: coordination-main + required: true + expected-eviction: noeviction + session: + deployment: session-main + required: true + expected-eviction: noeviction + + programs: + mode: functions-provisioned + set-version: ca-redis-programs-v1 + required-digest: sha256:... + + key-digests: + default-profile: sensitive-scope + profiles: + sensitive-scope: + algorithm: hmac-sha-256 + write-version: 2 + readable-versions: [1, 2] + material-refs: + 1: secret://redis/key-digest/hv1 + 2: secret://redis/key-digest/hv2 + rotation-mode: dual-read-delete + maximum-read-probes: 2 + coordination-scope: + algorithm: hmac-sha-256 + write-version: 3 + readable-versions: [3] + material-refs: + 3: secret://redis/key-digest/coord-hv3 + rotation-mode: cold-cutover + maximum-read-probes: 1 + opaque-id: + algorithm: sha-256 + write-version: 1 + readable-versions: [1] + rotation-mode: fixed + maximum-read-probes: 1 +``` + +role/deployment 항목의 존재만으로 client나 health bean을 만들지 않는다. 선택된 capability가 +role을 참조할 때만 해당 runtime을 조립한다. 숫자는 starter example일 뿐 production SLO의 +universal 정답이 아니다. typed validation과 environment-specific override가 필요하다. secret +값은 직접 YAML에 넣지 않는다. + +### 32.2 topology sum type + +Spring configuration binder가 sealed subtype을 자동 판별한다고 가정하지 않는다. binding model과 +validated runtime model을 분리한다. + +```text +@ConfigurationProperties("ca-skeleton.providers.redis") +RedisProviderProperties + Map deployments + Map roles + RedisProgramSetProperties programs + RedisKeyDigestProperties keyDigests + +RedisDeploymentProperties + topology: STANDALONE | SENTINEL | CLUSTER + standalone: StandaloneProperties? + sentinel: SentinelProperties? // master/endpoints + discovery authentication/TLS + cluster: ClusterProperties? + authentication/tls // selected data-node channel + +RedisDeploymentSettingsFactory + -> StandaloneSettings | SentinelSettings | ClusterSettings + +@ConfigurationProperties("ca-skeleton.capabilities") +CapabilitySelectionProperties + cache/rate-limit/idempotency/lock/security의 provider-neutral selection과 Redis role reference +``` + +`RedisDeploymentProperties`는 ordinary concrete `@ConfigurationProperties` record/class다. +factory가 discriminator와 exactly-one matching nested property를 검증하고 immutable runtime +sealed model을 만든다. non-selected nested property가 존재하거나 selected property가 빠지면 +startup failure다. + +binding test는 YAML/env -> properties -> factory -> exact runtime subtype 전 경로와 unknown/ +contradictory field를 검증한다. custom Spring binder/converter는 이 단순 factory model로 표현할 수 +없는 요구가 생길 때만 도입한다. + +### 32.3 capability shape + +```yaml +ca-skeleton: + capabilities: + cache: + bindings: + worklog-summary: redis + regions: + worklog-summary: + redis-role: cache + key-digest-profile: sensitive-scope + codec: + id: worklog-summary-json + write-version: 2 + readable-versions: [1, 2] + maximum-payload: 256KiB + negative-ttl: 30s + soft-ttl: 8m + hard-ttl: 10m + ttl-jitter: 0.10 + failure-mode: source-fallback + stampede: local-single-flight + + rate-limit: + provider: redis + degraded-provider: local-emergency + redis-role: coordination + key-digest-profile: coordination-scope + local-emergency: + maximum-entries: 10000 + entry-ttl: 2m + maximum-in-flight: 256 + assumed-maximum-pods: 20 + per-pod-share: 0.025 + policies: + login: + revision: v3 + algorithm: token-bucket + capacity: 10 + refill-tokens: 10 + refill-period: 1m + failure-mode: fail-closed + subject: [client-ip, route] + + idempotency: + provider: redis + redis-role: coordination + key-digest-profile: coordination-scope + guarantee: request-replay + processing-lease: 30s + replay-ttl: 24h + maximum-response: 64KiB + + lock: + bindings: + cache-refresh: redis + daily-export: jdbc + guarantees: + cache-refresh: cache-refresh-soft-lease + daily-export: efficiency-lease + redis-roles: + cache-refresh: cache + key-digest-profiles: + cache-refresh: sensitive-scope + + security: + auth-mode: redis-session + session: + redis-role: session + key-digest-profile: opaque-id + repository: versioned-simple + idle-timeout: 30m + absolute-lifetime: 12h + touch-interval: 1m + serializer: + id: session-json + write-version: 3 + readable-versions: [2, 3] + allowlisted-types: + - security-context-v1 + - csrf-token-v1 + maximum-payload: 64KiB + cookie: + name: WORKLOG_SESSION + secure: true + http-only: true + same-site: lax + path: / + domain: null # host-only; production fork가 필요한 경우에만 명시 + csrf: + enabled: true + token-strategy: cookie-request-attribute + fixation: + strategy: migrate-session + rotate-on: [login, privilege-elevation, sensitive-reauth] + persistence: + flush-mode: on-save + save-mode: on-set-attribute + tombstone: + ttl: 5m + revision-cas: true +``` + +`bindings`, singleton `provider`, `dispatch-mode`, `auth-mode`가 각각 activation 축이다. +region/provider 내부에 별도 `enabled`를 두지 않는다. `disabled`를 선택하면 해당 capability가 +비활성이다. 이 예시는 schema와 필수 보안 축을 고정하며 실제 product region/policy 값은 fork에서 +정의한다. + +### 32.4 validation + +startup 전 deterministic validation: + +- active capability에 provider/binding/mode 정확히 하나; +- rate-limit primary provider는 정확히 하나이며 degraded-provider는 `disabled` 또는 primary와 다른 + provider 하나; +- referenced role/deployment 존재; +- topology exact one; +- endpoint non-empty/unique; +- TLS production requirement; +- Sentinel discovery channel과 discovered data-node channel의 named credential/trust material을 + 각각 표현하고 production에서 둘 다 검증; +- selected role의 data-node ACL username/password reference와 explicit trust bundle; +- secret reference 형식; +- timeout positive/order; +- queue/pool non-negative/positive relation (`reject` mode만 disconnected buffer 0 허용); +- Cluster database 0; +- role/read preference compatible; +- TTL relationships; +- algorithm parameter completeness; +- local-emergency maximum entries/TTL/in-flight positive bound와 + `per-pod-share * assumed-maximum-pods <= 1`; +- key/codec/program version; +- key-digest algorithm/write/read versions, version별 material, rotation mode와 capability 호환성; +- provider guarantee가 requested guarantee 충족; +- incompatible role co-location; +- duplicate region/policy/provider ID; +- session serializer/cookie/CSRF/fixation/save/touch/tombstone 설정 완전성; +- `jwt`와 `redis-session` exclusivity 및 Redis Session에서 CSRF disable 거절; +- unknown setting fail closed where binder supports it. + +### 32.5 runtime handshake + +required capability activation: + +1. connection/DNS/TLS/auth; +2. server role/topology; +3. supported Redis version; +4. Cluster coverage/database; +5. required command/program availability; +6. program digest/result schema; +7. read/write probe on dedicated ephemeral namespace; +8. role policy attestation; +9. serializer/key schema registry; +10. health registration. + +probe key는 bounded TTL과 dedicated prefix를 사용하고 cleanup한다. user data namespace를 건드리지 +않는다. + +### 32.6 optional activation + +optional cache가 unavailable이라고 전체 application startup을 반드시 막지는 않는다. + +- config invalid/codec/program mismatch: startup fail; +- backend temporarily unavailable + declared optional: degraded startup 가능; +- session/idempotency/strict rate required: readiness/startup fail policy; +- descriptor에 actual state 표시. + +misconfiguration과 external outage를 구분한다. + +### 32.7 environment key registry + +새 setting은: + +- typed property; +- `application.yml` placeholder; +- env registry; +- `.env` example; +- binding/validation test; +- secret classification; +- documentation + +을 한 change set에서 갱신한다. + +현재 registry에만 있고 consumer가 없는 host/port/password/TTL key를 먼저 정리한다. + +### 32.8 dynamic refresh + +topology endpoint, credential rotation은 client lifecycle로 refresh할 수 있다. cache TTL/policy, +rate algorithm/revision, serializer/program version을 arbitrary live mutation하지 않는다. + +policy 변경: + +- new revision key; +- validate; +- shadow/canary; +- atomic registry switch; +- old state TTL drain. + +Spring `@RefreshScope`로 connection/serializer가 중간 상태가 되게 하지 않는다. + +## 33. Activation과 bootstrap + +### 33.1 provider selection + +선택은 typed ID로 한다. + +```text +ca-skeleton.capabilities.cache.bindings.=disabled|redis +ca-skeleton.capabilities.rate-limit.provider=disabled|local-emergency|redis +ca-skeleton.capabilities.rate-limit.degraded-provider=disabled|local-emergency +ca-skeleton.capabilities.idempotency.provider=disabled|jdbc|redis +ca-skeleton.capabilities.lock.bindings.=disabled|local|jdbc|redis +ca-skeleton.capabilities.security.auth-mode=jwt|redis-session +``` + +`@Primary`나 classpath 우연으로 선택하지 않는다. + +rate-limit의 exactly-one 규칙은 primary `provider`에 적용한다. `degraded-provider`는 별도 optional +축이고 primary와 같은 ID를 선택할 수 없다. selected primary가 Redis일 때만 Redis provider가, +selected degraded provider가 local-emergency일 때만 bounded local provider/composite가 생긴다. + +### 33.2 disabled behavior + +capability disabled: + +- no provider bean; +- no client/connection; +- no scheduler/watchdog; +- no script load; +- no health dependency; +- no metric polling; +- direct use 시 typed `CapabilityDisabledException`. + +empty optional cache router bean이 있다고 capability가 활성인 것은 아니다. + +### 33.3 current flag migration + +`APP_CACHE_REDIS_ENABLED`와 기존 `app.cache.*`/`app.redis.*`는 단계적으로 교체한다. + +Phase: + +1. old key를 canonical `ca-skeleton.capabilities.*`/`ca-skeleton.providers.redis.*`의 legacy + alias로만 읽고 deprecation log; +2. binding/provider/mode 없이 old enable만 true이면 activation을 거절; +3. old/new 값이 모순되면 precedence를 정하지 않고 startup failure; +4. canonical 값만 descriptor와 bean creation을 결정; +5. migration release 뒤 old key 제거; +6. env registry/public docs snapshot 갱신. + +enable boolean 하나로 host/role/region/guarantee를 추측하지 않는다. + +### 33.4 multi-instance safety + +현재 bean-name list 검사를 capability descriptor validation으로 바꾼다. + +예: + +```text +required: rate-limit/login GLOBAL +actual: local fixed-window +-> startup failure +``` + +```text +required: cache-refresh CACHE_REFRESH_SOFT_LEASE +actual: redis lease, cache role +-> allowed +``` + +```text +required: inventory STRICT_COORDINATION +actual: redis efficiency lease +-> startup failure +``` + +plain `Object` bean으로 통과할 수 없어야 한다. + +### 33.5 readiness composition + +bootstrap이 enabled required capability의 health를 readiness group에 포함한다. + +- optional cache backend down: ready + degraded detail; +- required session down: not ready; +- strict rate down: policy에 따라 not ready 또는 fail-closed serving; +- idempotency required mutation path down: not ready; +- unused role: health check 없음. + +### 33.6 profile exclusivity + +다음 contradiction을 거절한다. + +- JWT stateless + Redis Session filter 동시; +- session mode + session role absent; +- session role bound to evictable cache; +- idempotency Redis + JPA provider both active; +- same lock purpose에 JDBC/Redis both active; +- rate provider Redis + local limiter가 silent primary; +- Functions mode + digest absent. + +## 34. Security design + +### 34.1 network + +- public internet 직접 노출 금지; +- private endpoint/VPC/network policy; +- source security group 최소화; +- Redis client, replication, Cluster bus protection; +- management port 별도 통제; +- egress allowlist. + +application-level password만으로 network exposure를 정당화하지 않는다. + +### 34.2 TLS + +production: + +- TLS enabled; +- hostname verification enabled; +- trusted CA explicit; +- protocol/cipher policy; +- certificate expiry alert; +- SNI/managed endpoint test; +- Cluster/Sentinel 각 channel test; +- plaintext downgrade 금지. + +`trust-all`이나 hostname verification off는 local-only이며 production startup에서 거절한다. + +### 34.3 ACL identity + +workload별 named user: + +```text +cache-runtime +coordination-runtime +session-runtime +program-deployer +operator-readonly +``` + +default user는 production에서 disable한다. + +### 34.4 least privilege + +runtime user는 `reset -@all`에서 필요한 command/category/key/channel pattern만 부여한다. + +금지 대상 예: + +```text +CONFIG +ACL +MODULE +DEBUG +MONITOR +FLUSHALL/FLUSHDB +KEYS +MIGRATE +SHUTDOWN +FUNCTION LOAD/DELETE (runtime) +arbitrary EVAL (Functions profile) +``` + +ACL category가 새 Redis version에서 확장될 수 있으므로 allowlist와 negative integration test를 +사용한다. + +### 34.5 program deployment identity + +Function provisioning account와 application runtime account를 분리한다. + +- deployer: function library load/list/delete의 제한된 release workflow; +- runtime: `FCALL`과 data command; +- digest attestation; +- audit log; +- rollback artifact. + +EVALSHA compatibility profile은 runtime script-load 권한의 위험을 capability card에 기록한다. + +### 34.6 key pattern + +ACL key pattern을 role/capability prefix에 제한한다. application key builder와 ACL pattern이 +같은 versioned prefix registry에서 생성되도록 conformance test를 둔다. + +hash tag/user input으로 prefix를 탈출할 수 없어야 한다. + +### 34.7 secret source + +password/private CA/key material은: + +- secret reference; +- external secret manager/file mount; +- no source/YAML default; +- char/byte lifetime 최소화; +- structured log redaction; +- exception sanitization; +- rotation metadata. + +현재 generic fail-open logger가 raw exception message를 기록하는 경로는 endpoint/credential +leak 가능성을 검토하고 classified sanitized field만 남기도록 바꾼다. + +### 34.8 secret material provider contract + +Redis leaf가 provider-specific SPI와 immutable value를 소유한다. + +```java +public interface RedisCredentialMaterialProvider { + RedisCredentialResolution resolve(SecretReference reference); + RotationSubscription subscribe( + SecretReference reference, + RedisCredentialRotationListener listener); +} + +public record VersionedRedisCredentialMaterial( + SecretVersion version, + Instant expiresAt, + DestroyableSecret username, + DestroyableSecret password, + DestroyableTrustMaterial trustMaterial) {} +``` + +`RedisCredentialResolution`은 `Resolved`, `TemporarilyUnavailable`, `Expired`, +`InvalidReference`, `PermissionDenied`를 구분한다. material은 version과 expiry를 가지며 사용 후 +파기 가능한 byte/char representation으로 전달한다. secret value, reference 전체, provider +exception message를 metric/log에 남기지 않는다. + +app-bootstrap은 환경에 맞는 Vault/file/Kubernetes/managed-secret 구현을 조립하거나 generic +secret capability를 Redis SPI에 bridge한다. Redis leaf가 bootstrap이나 특정 secret vendor에 +역의존하지 않는다. listener는 새 version을 알릴 뿐 event thread에서 client를 직접 바꾸지 않고, +role runtime의 serialized rotation coordinator가 새 factory 검증, traffic switch, old connection +drain을 수행한다. + +subscription loss, duplicate/out-of-order event, resolve timeout, expired material, partial role +rotation을 test한다. event만 믿지 않고 expiry 전 bounded periodic re-resolve를 둔다. + +### 34.9 credential rotation + +rotation protocol: + +1. new credential/ACL 추가; +2. client dual-valid overlap; +3. new connection factory/session drain; +4. new credential connectivity/command test; +5. traffic switch; +6. old connections drain; +7. old credential revoke; +8. stale client alert. + +한 global connection을 즉시 끊어 모든 role이 동시에 outage되지 않도록 role별 수행한다. + +### 34.10 data at rest + +AOF/RDB/backup에는 value가 평문으로 남을 수 있다. + +- encrypted volume/managed KMS; +- backup encryption/access/retention; +- session/idempotency sensitive payload 최소화; +- application-level field encryption이 필요하면 별도 key rotation 설계; +- key names에도 PII 없음. + +### 34.11 untrusted input + +검증: + +- key length; +- policy/region ID allowlist; +- cost upper bound; +- TTL upper bound; +- payload size/depth; +- collection count; +- script args; +- numeric overflow; +- Unicode normalization; +- compression ratio. + +client가 Redis command name, key prefix, Lua source를 입력할 수 없다. + +### 34.12 SSRF와 endpoint + +Redis endpoint는 operator config에서만 온다. request/tenant가 host/port/database를 선택하지 않는다. +dynamic per-tenant Redis endpoint가 필요하면 별도 allowlisted tenancy control plane을 설계한다. + +### 34.13 audit + +audit 대상: + +- provider/role binding change; +- function deploy/rollback; +- ACL/credential rotation; +- destructive maintenance; +- mass invalidation; +- idempotency manual reconciliation; +- fencing high-watermark repair; +- session global revoke. + +audit에는 value/key/token/secret를 기록하지 않는다. + +## 35. Health와 observability + +### 35.1 health 의미 + +`PING` 성공만으로 다음을 보장하지 않는다. + +- write 가능; +- correct primary; +- Cluster slot coverage; +- persistence 정상; +- noeviction headroom; +- required Function version; +- serializer/key compatibility; +- ACL command permission. + +health는 capability와 role 관점으로 구성한다. + +### 35.2 liveness + +liveness는 Redis에 의존하지 않는다. Redis outage로 pod를 반복 재시작하면 connection storm과 +failover를 악화시킨다. + +### 35.3 readiness + +required role: + +- connection/auth/TLS; +- topology/primary; +- minimal read/write capability; +- program digest; +- recent success/error budget; +- queue saturation; +- role-specific requirement + +을 본다. + +optional cache는 readiness를 내리지 않을 수 있지만 `DEGRADED`를 표시한다. + +### 35.4 capability metrics + +공통: + +```text +redis.capability.operations +redis.capability.duration +redis.capability.inflight +redis.capability.queue.depth +redis.capability.timeouts +redis.capability.indeterminate +``` + +bounded tags: + +```text +deployment +role +capability +operation +outcome +topology +``` + +endpoint, key, tenant, user, session, owner token은 tag가 아니다. + +### 35.5 cache metrics + +```text +cache.lookup [hit, miss, negative, stale, unavailable, corrupt] +cache.write [stored, skipped, rejected, unavailable, indeterminate] +cache.invalidate +cache.source.load +cache.source.wait +cache.singleflight.join +cache.refresh.claim +cache.stale.age +cache.payload.bytes +``` + +region ID는 startup allowlist라 bounded tag로 허용할 수 있다. + +### 35.6 rate metrics + +```text +rate.decisions [allow, deny] +rate.enforcement [global, local-emergency, fail-open, fail-closed, shadow] +rate.algorithm +rate.indeterminate +rate.dedup.replay +rate.state.rejected +``` + +policy ID/revision은 bounded registry value다. subject는 tag/log에 넣지 않는다. + +### 35.7 lease/fencing metrics + +```text +lease.acquire [acquired, contended, unavailable, indeterminate] +lease.wait +lease.renew +lease.lost +lease.release [released, not-owner, indeterminate] +fence.issued +fence.rejected +fence.regression +``` + +resource digest도 metric tag로 쓰지 않는다. + +### 35.8 idempotency metrics + +```text +idempotency.claim [acquired, replay, in-progress, mismatch, unavailable] +idempotency.takeover +idempotency.renew +idempotency.complete +idempotency.owner-conflict +idempotency.indeterminate +idempotency.response.bytes +``` + +use-case/policy ID는 bounded registry일 때만 tag다. + +### 35.9 session metrics + +```text +session.load +session.save +session.touch +session.rotate +session.logout +session.corrupt +session.expired +session.reauth +session.repository.error +``` + +principal/session ID 없음. + +### 35.10 client/topology metrics + +```text +connect/reconnect +command timeout +queued/rejected command +pool acquire/saturation +MOVED/ASK +topology refresh/failure/age +sentinel failover +connection age +TLS/auth failure +NOSCRIPT +function digest mismatch +BUSY/slow program +``` + +### 35.11 server metrics + +operator monitoring: + +- `used_memory`, RSS, fragmentation; +- `mem_not_counted_for_evict`; +- `evicted_keys`, `expired_keys`; +- hit/miss; +- connected/blocked/rejected clients; +- replication role/link/lag/offset; +- AOF/RDB/rewrite/fork status; +- persistence error; +- commandstats/errorstats/latencystats; +- Cluster state/uncovered slots; +- slowlog/latency events; +- function/script memory/version. + +application이 server INFO 전체를 high-cardinality metric으로 무분별하게 export하지 않는다. + +### 35.12 tracing + +span: + +```text +redis capability operation +deployment/role +program name/version +outcome/certainty +duration +``` + +raw command argument/key/value를 기록하지 않는다. source cache load는 별도 child span으로 Redis +latency와 DB latency를 구분한다. + +### 35.13 logs + +structured event: + +```text +event +capability +deployment/role +operation +outcome +errorCategory +certainty +correlationId +programVersion +``` + +raw exception message는 sanitize한다. repeated outage는 rate-limit/sampling하고 state transition은 +반드시 남긴다. + +### 35.14 alerts + +최소 alert: + +- required role unavailable; +- queue rejection/saturation; +- indeterminate mutation 증가; +- eviction on coordination/session; +- noeviction OOM; +- memory headroom; +- persistence failure; +- replication link/failover; +- Cluster uncovered slot; +- program digest drift/BUSY; +- session error/re-auth spike; +- idempotency owner conflict; +- lease lost/fence regression; +- rate local-emergency duration; +- cache miss/source load storm. + +## 36. Lifecycle와 운영 제어 + +### 36.1 startup + +순서: + +1. typed config validation; +2. secret material resolution; +3. client resources; +4. topology/connect/auth; +5. program/schema capability; +6. role attestation; +7. provider binding; +8. health/readiness; +9. background refresh/watchdog/consumer. + +background task를 connection validation 전에 시작하지 않는다. + +### 36.2 graceful shutdown + +순서: + +1. readiness off/new traffic drain; +2. new cache refresh/rate background work 중단; +3. new lease/idempotency long operation 중단; +4. in-flight operation bounded wait; +5. owner-safe lease release best effort; +6. session save completion; +7. Pub/Sub/stream listener stop; +8. dedicated connection/pool close; +9. shared client resources close. + +release response가 없다고 key를 blind delete하지 않는다. + +### 36.3 deployment rollout + +rollout compatibility 순서: + +1. N reader가 N/N+1을 이해; +2. new program/function deploy; +3. digest 확인; +4. new application writer canary; +5. metrics/error 확인; +6. full rollout; +7. old payload/key/program TTL drain; +8. old reader/program 제거. + +### 36.4 maintenance mode + +destructive command는 application runtime에 없다. + +operator tool/job: + +- dry-run/report-only default; +- exact deployment/role/prefix; +- maximum keys/bytes; +- rate limit; +- approval/audit; +- resumable cursor; +- Cluster node coverage; +- `UNLINK` bounded batch; +- cancellation. + +### 36.5 cache warmup + +warmup은 optional: + +- known bounded hot set; +- source load budget; +- randomized pacing; +- readiness와 분리; +- failure가 app startup을 무한 block하지 않음; +- no full DB/keyspace scan by default. + +### 36.6 incident mode + +capability별 safe degradation switch: + +- cache: stale/source fallback budget; +- rate: fail closed/local emergency; +- session: re-auth/fail closed; +- idempotency: reject new mutation; +- lease: no new acquire; +- program mismatch: affected capability disable/fail. + +global “ignore Redis errors” switch는 없다. + +### 36.7 scaling + +client pod scale-out 전에: + +- Redis connection count; +- in-flight total; +- hot key; +- source fallback capacity; +- rate global key; +- session write amplification; +- topology refresh storm; +- credential/TLS handshake + +를 계산한다. pod 수를 늘리면 Redis와 source가 자동 확장된다고 가정하지 않는다. + +## 37. Test와 CI design + +### 37.1 원칙 + +fake Redis는 application policy unit test에는 유용하지만 다음을 증명하지 못한다. + +- command atomicity; +- TTL; +- wrong type; +- Lua/Function; +- script cache; +- Cluster slot; +- failover; +- replication/persistence loss; +- eviction/OOM; +- TLS/ACL; +- reconnect/replay. + +R2 provider는 real Redis integration이 필수다. + +### 37.2 application-core unit + +framework/Redis 없이 hand-rolled fake port로: + +- `CacheAsideExecutor` hit/miss/stale/unavailable; +- negative predicate; +- source failure/stale-if-error; +- `AuthoritativeAbsent`만 negative entry로 기록; +- `TransientFailure`/`PermanentFailure`/`Cancelled`는 negative entry로 기록하지 않음; +- stale은 `TransientFailure`에서만 policy에 따라 반환하고 permanent/unclassified failure에는 반환하지 + 않음; +- unclassified exception의 original cause/type를 보존하고 message를 cache/log/tag로 serialize하지 + 않음; +- local single-flight; +- source concurrency bound; +- idempotency claim outcome orchestration; +- owner lost/indeterminate; +- lease state/cancellation; +- business quota와 edge rate separation + +을 검증한다. + +### 37.3 shared edge contract + +- request/decision validation; +- bounded policy/subject/evaluation ID; +- cost overflow; +- retry/reset semantics; +- enforcement/certainty enumeration; +- no Servlet/Redis dependency; +- serialization snapshot if wire/shared value로 노출될 때. + +### 37.4 key builder unit/property + +property test: + +- same canonical input -> same key; +- different length-prefixed tuple -> collision 없음 within test corpus; +- raw PII substring 없음; +- maximum byte bound; +- invalid slug/braces 거절; +- HMAC version; +- rotation behavior; +- same resource atomic keys -> same slot; +- unrelated resource가 tenant-wide hot slot로 고정되지 않음; +- Cluster slot implementation과 real Redis `CLUSTER KEYSLOT` 일치. + +### 37.5 codec contract + +모든 codec: + +- round-trip; +- deterministic form where required; +- N/N-1 read; +- future version reject; +- corrupt length/digest; +- maximum encoded/decoded; +- nested/decompression bomb; +- null/negative marker; +- forbidden polymorphic type; +- secret/PII fixture redaction; +- rolling writer/reader matrix. + +JDK serialization marker나 native serialized payload가 fixture snapshot에 나타나면 실패한다. + +### 37.6 program descriptor gate + +build-time: + +- every source/function has descriptor; +- descriptor checksum matches resource; +- unique name/version; +- explicit key count; +- result schema version; +- complexity/state bound non-empty; +- minimum Redis version; +- retry/certainty classification; +- no dynamic source concatenation; +- banned command/static pattern scan. + +static scan은 semantic proof가 아니므로 real execution/concurrency test와 함께 사용한다. + +### 37.7 standalone integration + +Testcontainers real Redis에서: + +- connection/settings; +- byte serializer; +- TTL on every expirable write; +- counter first-write/TTL concurrency에서 immortal key 없음; +- compare-delete/expire/set에서 stale owner/revision mutation 차단; +- hash/set/sorted-set/list/bitmap/HLL/geo primitive의 byte/cardinality/range/offset bound; +- unbounded collection API와 raw command/source facade가 public surface에 없음; +- cache hit/miss/negative/stale; +- conditional put/invalidate; +- namespace generation; +- program load/invoke/result; +- `NOSCRIPT` recovery; +- wrong-type/corrupt entry; +- rate algorithms; +- owner-safe lease; +- idempotency state machine; +- session repository; +- health/metrics. + +container가 없으면 silently skip하지 않는 production-readiness task를 별도로 둔다. + +### 37.8 cache concurrency + +barrier-controlled tests: + +- N simultaneous miss -> local loader once; +- multiple pod simulation -> bounded distributed refresh owner; +- lease expiry -> duplicate load 허용 but no corrupt put; +- invalidation during load -> old generation invisible; +- update revision vs stale put; +- Redis outage -> bounded source concurrency; +- eviction storm -> no unbounded thread/queue; +- corrupt entry -> no infinite reload loop; +- negative cache cardinality bound. + +### 37.9 rate algorithm property + +각 algorithm: + +- exact/allowed approximation model과 reference implementation 비교; +- hundreds/thousands concurrent evaluation; +- window boundary; +- Redis server time movement; +- cost > 1; +- saturation/overflow; +- TTL cleanup; +- policy revision; +- evaluation dedup; +- response loss retry; +- Cluster same-slot; +- maximum state/member reject. + +fixed window의 boundary burst는 bug로 무조건 실패시키지 않고 declared property로 검증한다. +sliding counter는 declared error bound를 검증한다. + +### 37.10 lease/fencing concurrency + +- only current owner releases; +- old owner after expiry cannot release/renew; +- acquire response loss + same token inspect; +- renew response loss; +- holder pause longer than TTL; +- lost callback/cancellation; +- failover duplicate-holder scenario; +- new fenced resource PENDING -> provision -> ACTIVE; +- ACTIVE counter missing은 request-path reinitialize 금지; +- restore는 durable `(epoch, highWatermark)` 이상에서만 reprovision; +- epoch/registration mismatch와 retired resource; +- protected resource rejects stale fencing token; +- counter regression availability behavior; +- `close()` duplicate call; +- shutdown during renew. + +### 37.11 idempotency contract suite + +JPA와 Redis provider 공통: + +- first claim acquired; +- same fingerprint concurrent in progress; +- different fingerprint mismatch; +- completed replay; +- owner-safe renew/complete/release; +- stale owner blocked; +- expired `CLAIMED` takeover; +- expired `EXECUTING` -> `RECOVERY_REQUIRED`, no automatic re-execution; +- committed receipt reconciliation -> completed replay; +- authoritative no-effect evidence + expected revision -> reopened claim; +- stale/forged/conflicting reconciliation evidence CAS reject and audit; +- ordinary executor/controller에는 reconciliation port bean 주입 불가; +- verified evidence + authorized reconciler + durable audit 없이는 reconciliation 호출 불가; +- replay TTL separate; +- duplicate same complete idempotent; +- conflicting response digest rejected; +- oversize response; +- corrupt schema; +- unavailable/indeterminate. + +provider-specific: + +- Redis failover record loss/non-guarantee; +- JPA same-transaction claim/effect if advertised; +- JDBC/Redis cutover safety. + +### 37.12 session integration + +real Redis + multiple application contexts/pod simulation: + +- create on pod A/read on B; +- create/save response loss -> same operation/digest replay or inspect reconciliation; +- conflicting mutation after lost response -> no blind overwrite; +- idle touch; +- absolute expiry; +- login ID rotation; +- old ID reject; +- logout/delete; +- concurrent stale save after logout; +- revoke/rotate partial observation -> fail closed reconciliation; +- concurrent attribute update; +- corrupt payload -> invalidate/re-auth; +- N/N-1 serializer; +- repository outage; +- noeviction OOM; +- failover; +- cookie/CSRF/security filter behavior; +- JWT mode has zero session Redis connection. + +indexed repository는 Cluster/node-specific event와 orphan index cleanup을 별도 test한다. + +### 37.13 Sentinel topology + +최소 실제 topology: + +- primary; +- replica; +- independent Sentinel quorum. + +test: + +- client master discovery; +- primary kill; +- replica promotion; +- old primary partition/write; +- reconnect; +- in-flight mutation certainty; +- script/function availability; +- role/readiness event; +- credential/TLS. + +단일 fake Sentinel endpoint로 HA를 증명하지 않는다. + +### 37.14 Cluster topology + +최소 multi-primary Cluster와 replica에서: + +- slot coverage; +- same/cross-slot; +- `MOVED`/`ASK`; +- reshard; +- primary failover; +- topology refresh; +- advertised address; +- program on every primary; +- Pub/Sub/tracking profile; +- DB 0 validation; +- bounded redirects. + +### 37.15 TLS/ACL + +- trusted CA succeeds; +- untrusted CA fails; +- hostname mismatch fails; +- plaintext profile rejected in prod; +- wrong/rotated credential; +- old/new overlap; +- cache user cannot touch session prefix; +- runtime cannot `CONFIG`, `KEYS`, `FLUSH*`, function deploy; +- program deployer cannot read application values beyond required; +- `ACL DRYRUN` or equivalent conformance; +- exception/log secret redaction. + +### 37.16 memory/eviction + +real server config: + +- cache `allkeys-*` eviction; +- coordination/session `noeviction`; +- OOM write outcome; +- existing read behavior; +- Lua under memory pressure; +- big key rejection; +- eviction metric; +- cache and correctness deployment isolation; +- headroom alert inputs; +- `UNLINK` bounded cleanup. + +### 37.17 persistence/restart + +profiles: + +- no persistence; +- RDB; +- AOF configured mode. + +test: + +- graceful restart; +- kill/power-loss approximation; +- AOF rewrite; +- disk full/write error where CI environment supports; +- declared data-loss/RPO evidence; +- restore reconciliation; +- session security epoch; +- fencing high watermark. + +### 37.18 network fault matrix + +Toxiproxy/netem/process control로: + +| Fault | Expected | +| --- | --- | +| connect refused | known unavailable before send | +| latency > command timeout | read timeout or mutation indeterminate | +| response-only cut | applied-but-response-lost path | +| reconnect queue full | bounded reject, no heap growth | +| half-open connection | deadline/health transition | +| primary partition | failover semantics, no strong claim | +| DNS/endpoint change | re-resolution/rediscovery | +| TLS rotation | controlled reconnect | +| program busy | bounded failure/readiness | + +### 37.19 program failure + +- `SCRIPT FLUSH`; +- `NOSCRIPT`; +- wrong Function digest; +- missing library on one Cluster primary; +- result schema mismatch; +- malformed/wrong-type state; +- maximum argument; +- slow bounded program; +- intentionally long script in isolated test -> BUSY/alert/recovery; +- deployment rollback. + +무한 script를 shared CI Redis에서 실행해 worker를 영구 block하지 않는다. isolated disposable +container와 hard timeout을 사용한다. + +### 37.20 compatibility matrix + +minimum: + +- selected minimum Redis version; +- next supported minor; +- current approved major; +- managed-service compatible mode; +- Function mode; +- EVALSHA mode. + +matrix: + +- Spring Data Redis/Lettuce; +- program command set; +- key/codec result schema; +- session serializer/security version; +- topology. + +“latest” floating image를 release gate에 사용하지 않는다. digest/version을 pin한다. + +### 37.21 performance/capacity + +benchmark pass/fail을 가짜 universal TPS로 고정하지 않는다. regression suite는 동일 controlled +environment에서: + +- p50/p95/p99 operation duration; +- event-loop utilization; +- queue/in-flight; +- program server execution; +- memory per key; +- source fallback; +- hot-key throughput; +- failover recovery + +를 baseline 대비 비교한다. + +### 37.22 CI task + +구현 시 아래 task명을 그대로 Gradle 공개 계약으로 만든다. + +```text +:application-core:redisPolicyContractTest +:shared-contract:edgeRateLimitContractTest +:adapter:outbound:cache-redis:test +:adapter:outbound:cache-redis:redisStandaloneTest +:adapter:outbound:cache-redis:redisSecurityTest +:adapter:outbound:cache-redis:redisSentinelTest +:adapter:outbound:cache-redis:redisClusterTest +:adapter:outbound:cache-redis:redisFaultTest +:adapter:outbound:cache-redis:redisCompatibilityTest +:adapter:outbound:cache-redis:redisCacheCapabilityTest +:adapter:outbound:cache-redis:redisRateLimitCapabilityTest +:adapter:outbound:cache-redis:redisIdempotencyCapabilityTest +:adapter:outbound:cache-redis:redisSoftLeaseCapabilityTest +:adapter:outbound:cache-redis:redisFencedCoordinationCapabilityTest +:adapter:outbound:cache-redis:redisSessionCapabilityTest +:app-bootstrap:redisCompositionTest +redisCacheReadiness +redisRateLimitReadiness +redisIdempotencyReadiness +redisSoftLeaseReadiness +redisFencedCoordinationReadiness +redisSessionReadiness +redisProductionReadiness +redisAllImplementedCandidates +``` + +Redis leaf에는 `redisTest` source set을 만들고 source는 +`src/redisTest/java`, resource는 `src/redisTest/resources`에 둔다. 여섯 `redis*Test` task는 +동일 compiled source set에서 topology/evidence JUnit tag +`redis-standalone|redis-security|redis-sentinel|redis-cluster|redis-fault|redis-compatibility`와 +card tag +`card-redis-cache|card-redis-edge-rate-limit|card-redis-request-replay-idempotency| +card-redis-cache-refresh-soft-lease| +card-redis-fenced-coordination|card-redis-session`을 함께 사용한다. +선택된 card/evidence tag expression 결과가 0개면 실패하며 Docker/service 부재도 readiness lane에서 +skip하지 않는다. +application/shared/bootstrap의 contract task는 각 module의 별도 +`src/redisPolicyContractTest`, `src/edgeRateLimitContractTest`, `src/redisCompositionTest` source +set을 사용해 일반 unit test와 production-readiness evidence를 구분한다. + +card/evidence SSOT는 `src/config/redis/readiness-cards.yaml`로 고정한다. + +canonical card ID와 Gradle task mapping: + +| Card ID | Readiness task | +| --- | --- | +| `redis-cache` | `redisCacheReadiness` | +| `redis-edge-rate-limit` | `redisRateLimitReadiness` | +| `redis-request-replay-idempotency` | `redisIdempotencyReadiness` | +| `redis-cache-refresh-soft-lease` | `redisSoftLeaseReadiness` | +| `redis-fenced-coordination` | `redisFencedCoordinationReadiness` | +| `redis-session` | `redisSessionReadiness` | + +registry key, capability descriptor ID, `card-` tag, evidence artifact의 card ID는 이 표와 byte-for-byte +같아야 한다. short alias를 허용하지 않는다. + +```yaml +cards: + redis-cache: + state: selected # selected | implemented-candidate | not-implemented + selected-topology: sentinel # standalone | sentinel | cluster + required-evidence: + - standalone + - security + - fault + - compatibility + - selected-topology + redis-session: + state: not-implemented +``` + +각 `redisReadiness` task는 이 registry의 해당 card tag와 required evidence tag의 교집합을 +실행하고, category마다 test count > 0, 성공 artifact, image/program/config digest를 요구한다. +`selected-topology`는 registry의 exact topology tag로 치환한다. 다른 card의 test가 대신 evidence를 +채울 수 없다. + +task dependency는 다음으로 고정한다. + +- `:adapter:outbound:cache-redis:check` -> + `:adapter:outbound:cache-redis:redisStandaloneTest`; +- 각 card readiness -> 해당 capability test + required evidence/topology filtered test; +- root `redisProductionReadiness` -> registry에서 `state=selected`인 card readiness만; +- root `redisProductionReadiness` -> + application/shared contract, bootstrap composition, + `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyPublicPathSnapshot`, + `verifyConfigurationPropertiesProcessor`; +- selected card가 0개면 provider-disabled/zero-side-effect composition과 no-false-R2 descriptor를 + 검증하고 모든 card를 `not selected`로 보고하며 real Redis task를 가장해 실행하지 않음; +- `redisAllImplementedCandidates` -> `selected`와 `implemented-candidate` card 전체를 nightly + 실행하되 release label을 바꾸지 않음; +- release workflow는 root `redisProductionReadiness` 하나만 호출해 gate 누락을 막는다. + +CLI `-PredisCards=`로 release 선택을 바꿀 수 없고 checked-in registry와 release profile digest만 +selection authority다. `not-implemented` card의 test/tag가 0개인 것은 failure가 아니라 +`not selected`; selected card의 missing test/evidence만 failure다. + +server/container image SSOT는 `src/gradle/redis-test-images.properties`다. 최소 다음 key를 +version control한다. + +```properties +redis.minimum.image=/:@sha256: +redis.next-minor.image=/:@sha256: +redis.approved.image=/:@sha256: +toxiproxy.image=/:@sha256: +``` + +tag만 있거나 digest가 없거나 placeholder/`latest`이면 configuration 단계에서 실패한다. +Sentinel/Cluster container도 이 Redis image를 재사용하며 test resource config의 server version과 +manifest minimum version이 불일치하면 compatibility task가 실패한다. + +### 37.23 CI lane + +`.github/workflows/ci-quality-gates.yml`의 PR blocking job `redis-standalone`: + +- `:application-core:redisPolicyContractTest`; +- `:shared-contract:edgeRateLimitContractTest`; +- `:adapter:outbound:cache-redis:check`; +- `:app-bootstrap:redisCompositionTest`; +- 네 기존 gate + `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyPublicPathSnapshot`, + `verifyConfigurationPropertiesProcessor`. + +현재 branch protection이 단일 `release-gate` 집계 job을 required check로 사용하므로 workflow의 +집계 계약도 함께 바꾼다. + +```yaml +release-gate: + needs: + - quality-gates + - sample-off + - gate-matrix-lint + - redis-standalone +``` + +`Require every current blocking job to succeed` step에 +`REDIS_RESULT: ${{ needs.redis-standalone.result }}`를 추가하고 기존 result loop가 이 값도 +`success`인지 검사한다. job만 추가하고 `release-gate.needs`/검사 loop를 바꾸지 않은 상태는 +PR blocking으로 인정하지 않는다. workflow contract test는 blocking job set과 aggregator +`needs`/env/result-check set이 정확히 같은지 검증한다. + +새 `.github/workflows/redis-production-readiness.yml`은 `schedule`, `workflow_dispatch`, +release candidate trigger를 받는다. 첫 `resolve-redis-readiness` job이 checked-in card registry를 +검증하고 selected/implemented-candidate card와 task/digest matrix를 artifact/output으로 만든다. + +다음 topology job은 nightly의 공통 runtime/implemented-candidate qualification이다. + +| Job | Required task | +| --- | --- | +| `redis-security` | `redisSecurityTest` | +| `redis-sentinel` | `redisSentinelTest` | +| `redis-cluster` | `redisClusterTest` | +| `redis-fault` | `redisFaultTest` | +| `redis-compatibility` | `redisCompatibilityTest` | + +release candidate에서는 `selected-card-readiness` matrix가 selected card별 +`redisReadiness`를 병렬 실행한다. card가 요구하지 않는 Sentinel/Cluster나 미구현 capability +task는 release dependency가 아니다. 최종 `redis-production-readiness` job은 selected card +matrix가 모두 성공한 뒤 fresh runner에서 root `redisProductionReadiness`를 다시 실행해 registry +digest와 evidence artifact set을 대조한다. selected production topology, TLS/ACL, Function digest, +image/license, recovery/runbook drill evidence가 없으면 해당 card의 release readiness가 아니다. + +nightly `redis-all-candidates`는 `redisAllImplementedCandidates`를 실행한다. 그 실패는 candidate +품질 신호/승격 blocker지만 현재 selected card의 이미 존재하는 release evidence를 다른 card +미구현 때문에 자동 취소하지 않는다. + +각 job은 JUnit XML/HTML, container logs, sanitized topology/fault timeline, +`program-set.json`/digest, effective capability card, image digest attestation을 artifact로 올린다. +secret, raw Redis key/value, session/idempotency token은 artifact에 포함하지 않는다. PR artifact +retention은 짧게, release evidence는 조직의 audit retention 정책에 맞춘다. + +### 37.24 no silent skip + +developer local `test`는 Docker absence에서 explicit skipped report를 허용할 수 있다. 그러나 +`redisProductionReadiness`와 release CI는 selected card의 required evidence service unavailable을 +failure로 처리한다. unselected card는 skip이 아니라 `not selected`다. + +report에는: + +```text +executed +skipped with reason +not selected +failed +``` + +를 구분한다. + +## 38. Gradle, dependency, artifact design + +### 38.1 production dependency + +Redis leaf target: + +```groovy +dependencies { + implementation project(':application-core') + implementation project(':shared-contract') + implementation project(':adapter:outbound:support') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.data:spring-data-redis' + implementation 'io.lettuce:lettuce-core' + implementation 'org.slf4j:slf4j-api' + + // 실제 session repository provider를 이 leaf가 소유할 때만 둘 다 직접 선언 + implementation 'org.springframework.session:spring-session-core' + implementation 'org.springframework.session:spring-session-data-redis' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} +``` + +위 artifact와 ownership을 구현 계약으로 고정하고 호환 version은 Spring Boot 4 BOM과 lockfile이 +결정한다. dependency report는 runtime client가 정확히 하나인지와 direct ownership을 검증한다. +`:adapter:inbound:web`과 `:app-bootstrap`의 조건부 `spring-session-core` 직접 소유, Redis leaf의 +Servlet 금지, explicit auto-configuration suppression은 §31.3 표와 contract test를 따른다. + +### 38.2 starter policy + +application-core에 starter를 추가하지 않는다. Redis leaf도 broad starter가 불필요한 +auto-configuration/connection을 만들지 않도록 direct dependency와 explicit configuration을 +선호한다. + +starter를 사용하더라도: + +- capability disabled 시 side effect 없음; +- one global factory auto-created 안 됨; +- role별 qualifier; +- app-bootstrap이 composition owner + +를 test해야 한다. + +### 38.3 test dependency + +```text +Testcontainers JUnit +Testcontainers core/GenericContainer +Toxiproxy module +AssertJ/JUnit +property-test library already approved by repository +``` + +Redis-specific unofficial embedded server를 semantic evidence로 사용하지 않는다. + +Sentinel/Cluster config asset과 container orchestration은 test resources/scripts에 versioned로 둔다. + +### 38.4 dependency boundary + +architecture gate: + +- Redis/Spring Data/Lettuce/Session import는 Redis leaf/bootstrap/web의 approved package만; +- `domain-core`, `application-core`, `shared-contract`에는 없음; +- inbound web은 outbound Redis package에 의존하지 않음; +- JPA와 Redis provider leaf 간 직접 dependency 없음; +- sample production 역의존 없음. + +### 38.5 version baseline + +R2 portable minimum은 Redis 7.2로 고정한다. 실제 product image는 이 minimum 이상인 별도 +승인 exact version/digest로 고정한다. + +이유: + +- Redis Functions가 존재하는 generation; +- `WAITAOF` capability negotiation 가능; +- newer managed versions에서도 legacy Lua primitive 사용 가능. + +새er Redis command에 맞춰 baseline을 몰래 올리지 않는다. 예를 들어 newer conditional delete/set +command가 있어도 minimum matrix가 지원하기 전에는 owner-safe Function/Lua 구현을 유지한다. + +정확한 approved server image와 EOL/support policy는 product ADR에서 결정한다. + +### 38.6 license/image gate + +Redis release line에 따라 license 선택지가 달라질 수 있다. + +- image source; +- exact version/digest; +- organization legal approval; +- managed provider terms; +- vulnerability/EOL; +- upgrade/rollback + +을 ADR/CI metadata에 기록한다. floating `redis:latest`를 production/readiness evidence로 쓰지 +않는다. + +### 38.7 dependency lock + +새 production/test dependency 추가 시: + +- Boot BOM ownership 확인; +- BOM 밖 dependency version SSOT; +- `resolveAndLockAll --write-locks`; +- strict lock verification; +- runtimeClasspath에서 client exactly one; +- duplicate Netty/client conflicts; +- license/SBOM/security scan. + +### 38.8 program artifact + +Function/Lua는 JAR resource와 별도 deployable manifest를 함께 만든다. + +```text +program-set.json +functions/*.lua +scripts/*.lua +checksums.sha256 +compatibility.json +``` + +JAR implementation version/source revision과 program manifest를 연결한다. + +### 38.9 capability card artifact + +build가 machine-readable card를 생성한다. + +```text +provider IDs +readiness +roles +minimum Redis version +program set/digest +key/codec versions +guarantees/non-guarantees +required settings +test evidence profile +``` + +startup과 docs가 서로 다른 수동 목록을 유지하지 않게 한다. + +## 39. Implementation migration + +readiness는 Redis leaf 전체에 한 번에 부여하지 않고 capability card별로 승격한다. + +| Capability card | R2 최소 구현/evidence | 예정 phase | +| --- | --- | --- | +| `redis-cache` | standalone real provider, key/codec/TTL, soft/hard/stale, generation/invalidation, source bulkhead, outage/memory/security test | 1 + 2 + 5 | +| `redis-edge-rate-limit` | typed outcome, 선택 알고리즘 golden/property/concurrency/fault test | 2 + 5 | +| `redis-request-replay-idempotency` | owner-safe state machine, replay/lease TTL, JPA cutover, response-loss reconciliation | 3 + 5 | +| `redis-cache-refresh-soft-lease` | cache refresh integration, owner-safe renew/release/lost, duplicate-holder 허용 계약, fault test | 2 + 3 + 5 | +| `redis-fenced-coordination` | monotonic token과 protected-resource stale-token rejection evidence | 3 + 5, 선택 시에만 | +| `redis-session` | versioned repository, cookie/CSRF/fixation, multi-pod/logout race/failover test | 4 + 5 | + +공통 runtime가 R2 evidence를 가져도 미구현 capability는 R0이고, cache가 R2여도 session/rate가 +자동으로 R2가 되지 않는다. Phase 5는 각 card가 요구하는 selected topology/security/fault +evidence를 따로 묶어 승격한다. §37.22 registry의 해당 card가 `selected`이고 exact +`redisReadiness` evidence digest가 성공한 경우에만 descriptor를 R2로 바꾼다. + +### Phase 0 — current truth와 contract freeze + +- 현재 R0 seam/limitation을 README에 정직하게 표시; +- `APP_CACHE_REDIS_ENABLED`가 real client 없이 실패함을 문서화; +- current focused test 유지; +- cache/rate/idempotency/lease/session semantic contract 승인; +- capability descriptor/readiness/failure outcome; +- module/package migration ADR; +- implementation plan 작성. + +Acceptance: + +- 범용 Redis port 없음; +- current production-ready 오표기 없음; +- core dependency 방향 승인. + +### Phase 1 — runtime, key, codec, cache R1 / R2 foundation + +- Spring Data Redis + Lettuce direct dependency; +- standalone typed runtime; +- role/deployment config; +- key builder/HMAC/hash slot; +- codec/envelope; +- program registry; +- cache region port와 cache-aside executor; +- TTL/negative/invalidate/jitter; +- local single-flight/source bulkhead; +- real standalone integration; +- health/metrics/security baseline. + +Acceptance: + +- 실제 Redis cache provider 동작; +- disabled zero side effect; +- miss/unavailable/corrupt 구분; +- bounded TTL/payload/source load; +- no SDK in core; +- focused/architecture/readiness test 통과. + +### Phase 2 — advanced cache와 distributed rate + +- soft/hard TTL/stale; +- generation/revision invalidation; +- distributed refresh lease; +- edge rate shared contract; +- fixed/sliding counter/token bucket; +- policy registry/revision; +- evaluation dedup; +- local emergency failure policy; +- inbound HTTP mapping migration; +- current unbounded local map 제거/안전 fallback화. + +Acceptance: + +- multi-pod quota contract; +- algorithm concurrency/property; +- correct `Retry-After`; +- raw principal/IP key 없음; +- bean-name multi-instance validation 제거. + +### Phase 3 — owner-safe idempotency와 lease + +- idempotency port v2; +- JPA provider migration; +- Redis state machine; +- processing/replay TTL 분리; +- owner-safe complete/release; +- indeterminate reconciliation; +- lease v2/renew/lost; +- Redis efficiency provider; +- provider selector/cutover runbook; +- optional fenced contract와 resource fixture. + +Acceptance: + +- stale owner mutation 차단; +- cross-store non-guarantee 명시; +- failover/fault contract; +- JDBC/Redis provider 혼합 activation 없음. + +### Phase 4 — Redis Session + +- exclusive `jwt|redis-session`; +- session role/deployment; +- Spring Session repository; +- explicit serializer; +- cookie/CSRF/fixation; +- idle/absolute expiry; +- concurrent logout/save; +- multi-pod test; +- fail closed/readiness; +- indexed repository는 별도 opt-in. + +Acceptance: + +- JWT mode Redis side effect 0; +- session mode multi-pod/security/serializer/failure test 통과; +- cache deployment와 물리 격리. + +### Phase 5 — capability별 topology/security/failure R2 promotion + +- Sentinel profile; +- Cluster-compatible program/key; +- TLS/ACL; +- credential rotation; +- queue/backpressure/reconnect certainty; +- memory/eviction/persistence; +- fault matrix; +- production-readiness CI; +- runbook. + +Acceptance: + +- 승격 대상 capability마다 selected production topology R2 evidence; +- capability card별 evidence bundle과 readiness label; +- 증거가 없는 capability는 R0/R1 유지; +- no silent skip; +- program/ACL/schema conformance. + +### Phase 6 — R3와 split review + +- actual Cluster reshard/failover; +- rolling serializer/program/key upgrade; +- capacity soak; +- restore drill; +- fencing high-watermark recovery; +- multi-region 필요성 검토; +- capability별 leaf split trigger 재평가; +- external platform artifact 추출 검토. + +## 40. 완료 기준 + +특정 Redis capability card가 R2라고 주장하려면 아래 공통 조건과 §39의 해당 card 조건을 모두 +충족해야 한다. “Redis module 전체 R2”라는 단일 label은 사용하지 않는다. + +- real Redis client/provider가 있음; +- enabled capability가 실제 consumer port와 연결됨; +- disabled capability side effect 0; +- role별 deployment 분리; +- typed topology/TLS/ACL/timeout/queue setting; +- cache/idempotency/lease/session/rate failure policy 분리; +- key namespace/HMAC/version/hash-slot; +- explicit codec, no JDK serialization; +- bounded payload/collection/cardinality; +- value+TTL atomic write; +- versioned bounded program catalog; +- Function/Lua deployment/digest/recovery; +- mutation `INDETERMINATE` outcome; +- cache miss/unavailable/corrupt 분리; +- cache-aside/source bulkhead/stampede defense; +- policy별 rate algorithm과 fallback; +- owner-safe lease renew/release; +- idempotency owner token과 processing/replay TTL 분리; +- session cookie/CSRF/fixation/serializer/multi-pod; +- no cross-store exactly-once claim; +- no strong Redis lock claim; +- memory/eviction/persistence attestation; +- liveness/readiness 분리; +- bounded metrics/log/trace; +- graceful lifecycle; +- standalone real-service test; +- selected topology/failure/security test; +- dependency/architecture/env/public path gate; +- runbook/capability card; +- LLM Wiki capture. + +R3는 추가로: + +- failover/partition; +- Cluster reshard; +- rolling compatibility; +- recovery drill; +- capacity/latency evidence; +- credential/certificate rotation; +- program deployment rollback + +을 실제 topology에서 증명해야 한다. + +### 40.1 금지 문구 + +- “Redis는 single-thread라 multi-command도 race가 없다.” +- “Lua를 쓰므로 Redis 전체 성능에 영향이 없다.” +- “Lua/Function이므로 cross-store transaction이다.” +- “AOF와 replica가 있으므로 write loss가 없다.” +- “`WAIT`를 호출하므로 strong consistency다.” +- “Redis lock을 썼으므로 correctness가 보장된다.” +- “Redlock이면 fencing이 필요 없다.” +- “idempotency key가 있으므로 side effect가 exactly once다.” +- “Pub/Sub invalidation이 있으므로 stale cache가 없다.” +- “keyspace notification이 exact expiry event다.” +- “database number를 나눴으므로 session/cache가 격리됐다.” +- “PING이 성공하므로 Redis capability가 healthy다.” +- “timeout이므로 command는 실행되지 않았다.” +- “pipeline이므로 atomic하다.” +- “Spring Session을 추가했으므로 secure session이다.” +- “Testcontainers standalone이 통과했으므로 Cluster/HA도 production-ready다.” + +## 41. 운영 runbook 요구 + +각 항목은 detection, immediate mitigation, safety decision, recovery, verification을 포함한다. + +### Runtime/topology + +- connection/auth/TLS failure; +- DNS/managed endpoint change; +- Sentinel failover; +- Cluster `MOVED`/`ASK` storm; +- uncovered slot; +- advertised node unreachable; +- reconnect queue saturation; +- event-loop/connection exhaustion; +- rolling client upgrade. + +### Program/schema + +- Function missing/digest mismatch; +- `NOSCRIPT`; +- BUSY/slow script; +- result schema mismatch; +- key schema rolling migration; +- codec corrupt/future version; +- program rollback; +- missing program on one Cluster primary. + +### Cache + +- hit-ratio collapse; +- source load storm; +- hot/big key; +- mass invalidation; +- stale data incident; +- generation key loss; +- negative cache abuse; +- cache warmup/cold restart; +- L1 invalidation disconnect. + +### Rate limit + +- Redis unavailable; +- fail-closed incident; +- local emergency activation; +- hot global policy key; +- incorrect policy revision; +- evaluation double charge; +- algorithm migration/shadow; +- subject cardinality attack. + +### Lease/fencing + +- lease renewal loss; +- duplicate holder after failover; +- stale owner release; +- fencing token regression; +- protected resource rejection spike; +- leader task cancellation; +- semaphore permit leak; +- high-watermark repair. + +### Idempotency + +- stuck in-progress; +- owner takeover; +- complete indeterminate; +- fingerprint mismatch spike; +- response corruption/oversize; +- Redis record loss after business commit; +- JDBC/Redis provider cutover; +- manual reconciliation/abandonment. + +### Session + +- repository outage/re-auth spike; +- serializer incompatibility; +- session resurrection; +- mass logout/revoke; +- key/index orphan; +- absolute/idle expiry drift; +- backup restore security epoch; +- credential rotation; +- Cluster indexed repository cleanup. + +### Memory/persistence + +- cache eviction spike; +- coordination/session eviction; +- noeviction OOM; +- fragmentation/RSS; +- replication backlog; +- AOF/RDB failure; +- disk full; +- rewrite/fork latency; +- backup restore; +- capacity scale-out. + +### Security + +- credential compromise; +- ACL drift; +- unauthorized command attempt; +- certificate expiry/rotation; +- secret leakage in logs; +- unexpected public exposure; +- destructive operator command; +- Redis image/license/security update. + +## 42. Primary references + +### Redis execution and programmability + +- [Redis Lua scripting, atomic blocking execution, key declaration, and script cache](https://redis.io/docs/latest/develop/programmability/eval-intro/) +- [Redis Functions](https://redis.io/docs/latest/develop/programmability/functions-intro/) +- [Redis transactions and `WATCH`](https://redis.io/docs/latest/develop/using-commands/transactions/) +- [Redis multi-key operations](https://redis.io/docs/latest/develop/using-commands/multi-key-operations/) +- [Redis latency and slow-command guidance](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/) + +### Redis topology, durability, and memory + +- [Redis Cluster specification](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/) +- [Redis Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) +- [Redis replication](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/) +- [`WAIT`](https://redis.io/docs/latest/commands/wait/) +- [`WAITAOF`](https://redis.io/docs/latest/commands/waitaof/) +- [Redis persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/) +- [Redis key eviction](https://redis.io/docs/latest/develop/reference/eviction/) +- [`MEMORY USAGE`](https://redis.io/docs/latest/commands/memory-usage/) +- [`EXPIRE`](https://redis.io/docs/latest/commands/expire/) +- [Redis keyspace guidance and production `KEYS` warning](https://redis.io/docs/latest/develop/use/keyspace/) + +### Redis capability patterns + +- [Redis rate-limiter use case](https://redis.io/docs/latest/develop/use-cases/rate-limiter/) +- [Redis rate-limiter algorithm comparison](https://redis.io/tutorials/howtos/ratelimiting/) +- [Redis distributed lock pattern and limitations](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/) +- [Redis cache-aside](https://redis.io/docs/latest/develop/use-cases/cache-aside/) +- [Redis client-side caching](https://redis.io/docs/latest/develop/clients/client-side-caching/) +- [Redis session-store use case](https://redis.io/docs/latest/develop/use-cases/session-store/) +- [Redis Pub/Sub](https://redis.io/docs/latest/develop/pubsub/) +- [Redis keyspace notifications](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/) +- [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/) + +### Security and operations + +- [Redis security](https://redis.io/docs/latest/operate/oss_and_stack/management/security/) +- [Redis ACL](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/) +- [Redis TLS](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/) +- [Redis latency monitoring](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency-monitor/) +- [Redis `SLOWLOG`](https://redis.io/docs/latest/commands/slowlog/) +- [Redis CLI key and hot-key inspection](https://redis.io/docs/latest/develop/tools/cli/) +- [Redis licenses](https://redis.io/legal/licenses/) + +### Java client and Spring + +- [Spring Data Redis reference](https://docs.spring.io/spring-data/redis/reference/) +- [Spring Data Redis drivers](https://docs.spring.io/spring-data/redis/reference/redis/drivers.html) +- [Spring Data Redis scripting](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html) +- [Spring Data Redis transactions](https://docs.spring.io/spring-data/redis/reference/redis/transactions.html) +- [Spring Data Redis pipelining](https://docs.spring.io/spring-data/redis/reference/redis/pipelining.html) +- [Spring Data Redis serialization](https://docs.spring.io/spring-data/redis/reference/redis/template.html) +- [Spring Session repository APIs](https://docs.spring.io/spring-session/reference/api.html) +- [Spring Session Redis configuration and indexed-repository caveat](https://docs.spring.io/spring-session/reference/configuration/redis.html) +- [Lettuce command execution reliability](https://redis.github.io/lettuce/advanced-usage/command-execution-reliability/) +- [Lettuce client options](https://redis.github.io/lettuce/advanced-usage/client-options/) diff --git a/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md b/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md new file mode 100644 index 0000000..dffa0c0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md @@ -0,0 +1,7030 @@ +# HTTP Client Production Capability Deep Design + +- 작성일: 2026-07-27 +- 상태: 상세 설계 완료, Phase 0/1 기반 및 legacy deadline R1 구현, R2 미구현 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-httpclient` +- 구현 추적: typed operation/target foundation, legacy JDK 안전 결함과 active logical deadline + 단면까지 적용되었다. +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) + +## 0. 구현 상태 + +2026-07-28 기준 구현된 범위: + +- `application-core`의 framework-free, monotonic, parent-capped `CallBudget`; +- bounded `HttpDestinationId`, versioned `HttpOperationId`; +- method/route/semantics/request-response mode/success status/retry/physical-attempt/body 상한을 + 고정하는 immutable `HttpOperationDescriptor`와 closed catalog; +- fixed base authority와 registered relative route/path-segment만 조합하는 target builder; +- absolute/scheme-relative/traversal/pre-encoded target, base URI user-info/query/fragment 거부; +- legacy JDK client redirect `NEVER` 명시; +- legacy streaming의 status-first 검증과 error body callback 차단; +- retry 바깥에 physical-attempt circuit breaker를 두어 각 wire attempt를 독립 집계하는 수정. +- caller/configured monotonic `CallBudget` intersection을 buffered/streaming 실행 경로에 연결; +- blocking logical call과 retry/backoff를 cancellable virtual thread에서 실행하고 cutoff 시 + interrupt/`DEPENDENCY_TIMEOUT` 처리; +- client별 bounded live-worker admission, non-cooperative worker의 slot retention, shutdown 시 + active task cancellation과 신규 admission 차단; +- worker MDC 복사/정리와 retry ThreadLocal lifecycle 정렬. + +아직 구현되지 않은 범위: + +- application feature-specific production port와 실제 upstream anti-corruption adapter; +- canonical binding/expected-state/full profile tuple/card registry와 zero-binding resource 0 계약; +- Apache HC5 pool/acquire/lifetime/idle provider; +- Apache engine phase별 deadline 전달, wire hard cancellation과 connection quarantine; +- DNS/address/SSRF/TLS/mTLS/proxy/auth/secret lifecycle; +- wire/decoded/error/streaming body 전체 제한과 codec/media contract; +- OTel 단독 propagation과 manual trace interceptor 제거; +- real-network/TLS/pool/failure qualification 및 R2 readiness card. + +따라서 현재 `OutboundHttpClient`는 migration용 JDK R1 이하 facade이며 HTTP capability R2가 아니다. +legacy 실행 경로는 active logical deadline을 사용하지만 operation catalog와 engine phase +deadline을 아직 사용하지 않는다. 이 단면만으로 hard cancellation이나 R2를 주장하지 않는다. + +## 1. 설계 판정 + +현재 HTTP Client 모듈은 timeout, response-size cap, retry, circuit breaker, shutdown guard, +진단 로깅을 가진 유용한 골격이다. 그러나 운영에서 필요한 보장을 실제로 제공하는 +production capability는 아니다. + +현재 가장 큰 문제는 기능의 수가 아니라 보장의 정확성이다. + +1. `globalCallTimeout`은 실제 실행을 중단시키는 total deadline이 아니다. +2. README가 설명한 retry/circuit-breaker 순서와 코드의 실제 합성 순서가 반대다. +3. streaming 경로는 HTTP status를 검사하지 않고 4xx/5xx body도 reader에 전달한다. +4. method만으로 retry 안전성을 판단하며 body replayability와 upstream idempotency 계약이 없다. +5. JDK client의 pool, DNS, TLS, redirect, proxy, cancellation과 lifecycle이 운영 계약에 없다. +6. 모든 dependency가 하나의 global settings를 공유하며 실제 destination registry가 없다. +7. sample의 feature-specific port와 fixture consumer는 있지만, production semantic port 구현과 + 실제 HTTP operation/binding이 없다. +8. manual trace header가 실제 tracer의 sampling 결정을 훼손하고 tenant baggage를 모든 목적지로 + 보낼 수 있다. +9. arbitrary URI, header, credential, redirect와 DNS rebinding에 대한 egress 보안 경계가 없다. +10. unknown mutation outcome, partial body, decode failure, pool timeout과 cancellation이 + `4xx/5xx/connect/timeout` 몇 개로 합쳐진다. + +이번 설계의 목표는 범용 `execute(method, url, body)` SDK가 아니다. + +> feature-specific application port 뒤에서, 등록된 destination과 operation만 호출하고, +> 하나의 monotonic deadline 안에서 admission·pool·DNS·connect·TLS·write·response·body·retry를 +> 제한하며, retry safety·unknown outcome·보안·관측성·취소를 실행 가능한 계약으로 증명하는 +> outbound HTTP capability + +선택한 핵심 구조는 다음과 같다. + +1. Application은 `OutboundHttpClient`가 아니라 `FraudScreeningPort`, + `PartnerCatalogPort` 같은 feature-specific anti-corruption port에 의존한다. +2. HTTP method, path template, success status, media type, retry safety와 body replayability는 + adapter의 typed operation catalog에 등록한다. +3. 호출자는 arbitrary absolute URL이나 raw credential header를 전달하지 않는다. +4. destination binding과 provider activation은 canonical configuration 하나로 결정한다. +5. 하나의 total deadline은 모든 대기와 physical attempt를 포함하며 timeout 때 engine call과 + response stream을 적극 취소한다. +6. retry는 method 이름만이 아니라 operation semantics, request identity, replayable body, + failure phase, remaining budget를 모두 만족할 때만 가능하다. +7. mutation이 전송된 뒤 응답을 잃으면 일반 transient failure가 아니라 + `INDETERMINATE`로 반환하고 provider-specific inspect/reconcile을 요구한다. +8. circuit breaker는 기본적으로 physical attempt를 집계하고, 논리 호출 집계는 별도 이름의 + 선택 정책으로만 허용한다. +9. Apache HttpComponents 5 classic + blocking facade를 초기 R2 reference candidate로 삼고, + hard-cancel evidence를 통과할 때만 baseline provider로 승격한다. JDK/HTTP2/reactive engine도 + 같은 보장을 통과할 때만 R2로 승격한다. +10. OTel instrumentation이 trace propagation을 단독 소유하며 manual MDC `traceparent` + writer는 제거한다. +11. no binding이면 client, pool, evictor, credential refresh, DNS probe가 하나도 생성되지 않는다. +12. capability별 readiness card가 실제 선택된 operation profile과 engine evidence를 검증한다. + +## 2. 상위 통합 설계와 이번 심화 범위 + +상위 설계의 HTTP client 절은 다음 방향을 이미 정했다. + +- named client registry와 dependency별 설정; +- pool total/per-route/acquisition/idle/DNS/lifecycle; +- bulkhead와 optional outbound rate limit; +- declared-safe operation만 retry; +- total deadline과 active cancellation; +- redirect와 SSRF 방어; +- TLS, mTLS, proxy, certificate rotation; +- request/response bounds; +- upload/download streaming; +- OTel trace ownership; +- failure injection과 pool exhaustion test; +- application의 feature-specific anti-corruption port. + +이번 문서는 위 방향을 구현 계획으로 변환할 수 있도록 다음을 확정한다. + +- application port와 adapter-internal kernel의 정확한 경계; +- destination, operation, attempt, logical call의 identity; +- request/response/body 타입과 lifecycle; +- absolute deadline과 phase budget; +- retry, circuit breaker, bulkhead, rate limiter의 정확한 실행 순서; +- safe/idempotent/replayable/unknown outcome의 차이; +- status별 default와 operation override; +- pool과 HTTP/1.1·HTTP/2 capacity model; +- DNS refresh, IP allowlist, rebinding, redirect와 proxy 검증; +- TLS trust, hostname verification, mTLS와 credential rotation; +- buffered, streaming upload/download, compression과 decode limit; +- error taxonomy와 application mapping; +- metrics, span, log, health와 cardinality; +- typed activation/configuration과 zero-side-effect 비활성 상태; +- provider engine 선택과 Gradle dependency ownership; +- real-network, TLS, DNS, proxy, failure-injection test; +- capability별 R2 readiness card와 CI aggregate; +- 기존 `OutboundHttpClient`에서의 단계적 migration. + +## 3. 증거 기반 현재 상태 + +### 3.1 실제 HTTP capability binding이 없다 + +현재 `adapter-outbound-httpclient`는 `shared-contract`와 +`adapter-outbound-support`에만 project dependency를 둔다. Registry는 application/domain edge를 +허용하지만 production code는 application의 어떤 semantic port도 구현하지 않는다. + +`OutboundHttpClient`는 adapter package의 기술 타입이며 기본 bean도 없다. README는 fork가 +dependency별 configuration에서 `baseline(...)`을 직접 호출하도록 안내한다. repository 전체에서 +이를 호출하는 production consumer는 없다. + +다만 “binding 0개”가 HTTP 관련 bean 0개라는 뜻은 아니다. Component scan이 이 configuration을 +읽으면 `OutboundHttpSettings`, shutdown guard, `RestClient`/builder 차단 BeanPostProcessor, +error mapper, logger와 retry policy 같은 global infrastructure bean은 생성된다. Named +client/semantic-port binding은 없는데 required global timeout 설정과 전역 부작용은 존재하는 +비대칭 상태다. + +다만 sample에는 이미 다음 seam이 있다. + +- `RepoStatsPort`; +- `GetRepoStatsUseCase`; +- `RepoStatsPortClient` stub; +- `/worklogs/repoStats` endpoint. + +이는 application port와 consumer skeleton이 없다는 뜻이 아니다. `RepoStatsPortClient`의 +`fetchRaw()`가 고정 fixture를 반환하므로 실제 HTTP capability를 소비하지 않는다는 뜻이다. +현재 sample registry edge에도 `sample-portfolio -> adapter-outbound-httpclient`가 없다. + +따라서 정확한 현재 상태는 다음과 같다. + +```text +HTTP leaf: + settings + generic technical wrapper + tests + +sample: + feature-specific port + use case + fixture adapter + +but: + no real HTTP operation/binding between them + != +feature-specific application port + upstream adapter + runtime binding +``` + +현재 capability level은 R0 seam과 일부 R1 local kernel 사이이며 R2가 아니다. + +### 3.2 total deadline이 실행을 제한하지 않는다 + +`OutboundHttpClient.exchange()`는 다음 deadline을 계산한다. + +```java +Instant deadline = Instant.now().plus(settings.globalCallTimeout()); +retryPolicy.beginCall(method, deadline); +``` + +그러나 이 값은 `OutboundRetryPolicy.shouldRetry()`가 다음 retry를 시작할지 확인할 때만 사용한다. +현재 attempt를 중단하지 않고 다음도 포함하지 않는다. + +- connection/pool wait; +- DNS; +- connect와 TLS handshake; +- request body write; +- response header wait; +- response body read; +- streaming reader callback; +- retry backoff 자체; +- circuit-breaker/bulkhead wait. + +첫 attempt가 `globalCallTimeout`보다 오래 걸려도 read timeout 전까지 계속 실행된다. Retry +predicate가 deadline 직전 true를 반환하면 남은 시간보다 긴 새 attempt도 시작할 수 있다. +`stream()`은 이 deadline을 검사만 하는 수준도 아니며 absolute deadline/retry context/cancel +handle을 아예 만들거나 전달하지 않는다. 그러므로 application.yml의 “whole call including +retries” 주석과 runtime behavior가 일치하지 않는다. + +### 3.3 retry와 circuit breaker 합성 설명이 코드와 반대다 + +현재 코드는 다음 순서로 decorator를 만든다. + +```java +Supplier decorated = countingSupplier; +decorated = Retry.decorateSupplier(retry, decorated); +decorated = CircuitBreaker.decorateSupplier(cb, decorated); +``` + +실행 구조는 다음과 같다. + +```text +CircuitBreaker( + Retry( + physical attempt + ) +) +``` + +따라서 circuit breaker는 retry가 끝난 논리 호출 하나를 집계한다. README와 코드 주석은 +“retry가 circuit breaker 바깥이고 각 attempt가 독립 집계된다”고 설명한다. 원하는 physical +attempt 집계는 다음 구조여야 한다. + +```text +Retry loop( + CircuitBreaker( + one physical attempt + ) +) +``` + +현재 test는 retry hit count와 meter 존재를 확인하지만 circuit-breaker sliding window가 physical +attempt를 몇 건 집계했는지는 검증하지 않는다. + +### 3.4 streaming은 status error를 성공 body처럼 전달한다 + +buffered 경로는 `retrieve()`를 사용해 default 4xx/5xx handler가 예외를 발생시킨다. Streaming +경로는 `exchange(...)` callback을 사용하면서 status를 확인하지 않는다. + +Spring Framework는 `RestClient.exchange()`에서 status handler가 자동 적용되지 않는다고 +명시한다. Callback이 status를 직접 처리해야 한다. + +현재 구현: + +```java +streamingClient + .method(method) + .uri(uri) + .exchange((req, res) -> reader.apply(res.getBody())); +``` + +그 결과 401, 429, 500도 body reader가 정상 결과로 만들 수 있고 +`observer.recordSuccess(...)`가 호출된다. + +### 3.5 streaming이 size limit의 무제한 우회 경로다 + +현재 buffered response는 `Content-Length`와 counting stream으로 10 MB 기본 한계를 확인한다. +그러나 streaming 경로에는 다음 제한이 없다. + +- maximum wire bytes; +- maximum decoded bytes; +- logical total deadline across retries/backoff; +- 별도의 no-progress body idle deadline; +- content type와 content encoding; +- record/item count; +- reader output size; +- compression expansion ratio. + +현재 `JdkClientHttpRequestFactory#setReadTimeout`은 selected Spring Framework 7 구현에서 +`sendAsync` completion에 timer를 걸고 timeout 때 future/response body를 cancel/close하는 +attempt elapsed timeout에 가깝다. 이를 socket byte-idle timeout이나 logical-call total +deadline이라고 부르지 않는다. Non-cooperative callback의 CPU loop도 제한하지 못한다. + +README와 test는 buffered limit을 넘으면 streaming API를 사용하면 된다고 설명하고, test는 +oversized response 전체를 성공적으로 읽는 것을 기대한다. Streaming은 heap materialization을 +피하는 방법이지 무제한 데이터 허용 권한이 아니다. + +### 3.6 request 계약이 너무 넓고 동시에 부족하다 + +현재 API: + +```java + T exchange(HttpMethod method, String uri, Object requestBody, Class responseType) + T stream(HttpMethod method, String uri, Function reader) +``` + +문제: + +- raw Spring `HttpMethod`와 transport `InputStream`이 public adapter API에 노출된다. +- `String uri`가 relative path인지 absolute URI인지 강제하지 않는다. +- path/query encoding과 template variable allowlist가 없다. +- caller가 operation ID나 low-cardinality route template을 제공하지 않는다. +- request header, `Accept`, `Content-Type`, API version, conditional header를 표현하지 못한다. +- authentication profile이나 credential ownership이 없다. +- status별 success/domain outcome mapping이 없다. +- `Class`는 generic collection과 versioned envelope를 충분히 표현하지 못한다. +- request body byte limit과 replayability가 없다. +- operation-specific deadline과 resilience policy가 없다. + +이 API를 application에 그대로 노출하면 Clean Architecture의 anti-corruption boundary가 사라진다. + +### 3.7 retry safety가 method set 하나에 묶여 있다 + +현재 retry allowlist는 `GET`, `HEAD`, `PUT`, `DELETE`다. `POST`, `PATCH`는 항상 거부한다. + +HTTP method의 idempotency는 중요한 출발점이지만 충분하지 않다. + +- upstream이 PUT/DELETE를 문서와 다르게 구현할 수 있다. +- request body stream이 다시 열리지 않을 수 있다. +- idempotent effect여도 각 response는 다를 수 있다. +- conditional request가 precondition 없이 재실행될 수 있다. +- POST/PATCH도 upstream이 durable idempotency-key와 replay contract를 제공하면 안전할 수 있다. +- connect reset이 request 전송 전인지 후인지 모르면 mutation outcome은 불명확하다. + +RFC 9110도 non-idempotent request 자동 retry는 operation semantics를 실제로 알거나 최초 요청이 +적용되지 않았음을 아는 경우가 아니면 하지 말라고 요구한다. + +### 3.8 모든 4xx와 많은 transport error가 뭉친다 + +현재 error mapper는: + +- 400~499 전체를 `DEPENDENCY_4XX_CLIENT`; +- 500 이상 전체를 `DEPENDENCY_5XX_SERVER`; +- 알 수 없는 failure를 `DEPENDENCY_CONNECT_FAILED`; +- shutdown reject를 `DEPENDENCY_CIRCUIT_OPEN`; +- connect timeout을 `DEPENDENCY_CONNECT_FAILED`; +- 나머지 timeout을 `DEPENDENCY_TIMEOUT`; + +으로 분류한다. + +이 분류로는 다음을 결정할 수 없다. + +- 401 credential refresh 후 정확히 한 번 replay 가능한가; +- 404가 정상적인 absence인가 API drift인가; +- 408/425/429의 retry 조건은 무엇인가; +- 409/412가 domain conflict/precondition outcome인가; +- 413이 request contract 위반인가; +- 415/406이 media negotiation drift인가; +- 422가 permanent validation인가 idempotency fingerprint mismatch인가; +- 502/503/504와 일반 500의 retry 차이는 무엇인가; +- TLS trust, hostname, certificate expiry, proxy auth failure는 무엇인가; +- pool acquire와 local admission reject를 구분할 수 있는가; +- response가 일부 도착한 뒤 잘렸는가; +- mutation이 적용됐는지 모르는가. + +### 3.9 JDK client의 운영 자원 정책이 없다 + +현재 factory는 destination마다 다음 client를 만든다. + +```java +HttpClient.newBuilder() + .connectTimeout(settings.connectTimeout()) + .build(); +``` + +명시하지 않은 항목: + +- executor; +- HTTP version; +- redirect; +- proxy; +- authenticator/cookie handler; +- SSL context/parameters; +- connection capacity와 per-route isolation; +- acquisition timeout; +- connection TTL/idle validation; +- DNS resolver/TTL/address selection; +- shutdown/close ownership; +- pool metrics. + +Java 21 JDK client에는 builder-level total/per-route pool과 connection lease timeout을 구성하는 +API가 없다. `jdk.httpclient.connectionPoolSize`는 HTTP/1.1 keep-alive cache의 구현 property이며 +application-level admission이나 per-destination active-call bound를 대체하지 않는다. + +### 3.10 global settings가 destination 정책을 표현하지 못한다 + +`app.outbound.http.*` 하나가 모든 dependency에 적용된다. + +실제 운영에서는 다음이 destination마다 다르다. + +- base URI와 allowed address; +- API version; +- required/optional readiness impact; +- timeout/SLO; +- pool capacity; +- protocol version; +- TLS trust and mTLS identity; +- authentication; +- proxy; +- request/response size; +- retryable statuses와 idempotency contract; +- circuit-breaker threshold; +- rate quota; +- trace/baggage/privacy policy. + +현재 required global timeout placeholder는 실제 client binding이 0개여도 bootstrap 설정을 +요구한다. 반대로 client가 여러 개여도 서로 다른 정책을 지정할 수 없다. + +### 3.11 manual trace propagation은 fork landmine이 아니라 현재 결함이다 + +`TraceContextPropagationInterceptor`는 MDC의 `trace_id`와 `span_id`로 `traceparent`를 만들고 +sampled flag를 항상 `00`으로 기록한다. 실제 tracer가 있어도 먼저 설정된 header를 보존하도록 +구성되면 downstream sampling decision을 끊을 수 있다. + +추가로 `request_id`와 `tenant_id`를 `baggage`로 모든 destination에 전파한다. Tenant ID가 내부 +service boundary에서는 필요할 수 있어도 외부 partner에 보내도 된다는 뜻은 아니다. Destination별 +data-sharing policy 없는 global allowlist는 privacy boundary가 아니다. + +W3C `traceparent`/`tracestate` mutation과 sampling flag는 tracer propagator가 소유해야 한다. +MDC reconstruction은 fallback으로도 기본 활성화하지 않는다. + +### 3.12 observability가 logical call과 attempt를 구분하지 않는다 + +현재 logger는 dependency, outcome, total duration, retry count를 남기지만: + +- operation ID가 없다. +- HTTP status class가 없다. +- pool/DNS/connect/TLS/write/header/body phase가 없다. +- physical attempt별 span과 duration이 없다. +- cancellation과 indeterminate outcome이 없다. +- request/response bytes가 없다. +- actual HTTP client duration meter가 없다. +- pool leased/available/pending meter가 없다. + +Resilience4j metric filter는 shared `MeterRegistry`에 전역으로 설치되고 +`resilience4j.*` 중 세 meter 외 전부 deny한다. 동일 registry를 사용하는 다른 capability의 +Resilience4j meter까지 차단할 수 있다. 현재 singleton configuration에서는 한 번 설치되지만, +다중 ApplicationContext 또는 fork가 config를 수동 구성하면 같은 global filter가 반복 설치될 수 +있다. + +### 3.13 lifecycle guard가 drain/cancel/close를 수행하지 않는다 + +`OutboundHttpShutdownGuard.stop()`은 boolean을 변경할 뿐: + +- in-flight call count; +- graceful drain deadline; +- pending retry/backoff cancellation; +- response stream close; +- HTTP engine close; +- idle evictor stop; +- credential refresh scheduler stop; +- forced cancellation; + +을 수행하지 않는다. + +새 호출을 reject하는 것은 필요하지만 resource lifecycle 전체는 아니다. Shutdown을 +`DEPENDENCY_CIRCUIT_OPEN`으로 재사용하는 것도 잘못된 operational diagnosis다. + +### 3.14 현재 test가 증명하는 범위 + +2026-07-27 baseline: + +```text +./gradlew :adapter:outbound:httpclient:check --rerun-tasks --console=plain +BUILD SUCCESSFUL +``` + +현재 unit/local test는 다음을 증명한다. + +- settings의 일부 numeric validation; +- local JDK HttpServer에 대한 200/4xx/5xx/read-timeout mapping; +- GET retry hit count와 POST no-retry; +- circuit-open short-circuit; +- response `Content-Length`/chunked counting; +- manual MDC header propagation; +- basic logs와 Resilience4j meters; +- shutdown boolean gating. + +증명하지 않는 항목: + +- real total deadline와 hard cancellation; +- CB physical-attempt count; +- streaming 4xx/5xx rejection; +- connection pool saturation/acquisition/idle/TTL; +- TLS/mTLS/hostname/certificate rotation; +- DNS TTL/rebinding/address failover; +- redirect/proxy/SSRF; +- compressed body expansion; +- streaming cancellation/partial output; +- mutation unknown outcome와 idempotency replay; +- OAuth token refresh; +- HTTP/2 multiplexing/GOAWAY; +- process shutdown drain; +- multi-destination isolation; +- real observability instrumentation. + +현재 compile에는 `ThreadLocalUsage`, locale 없는 `toUpperCase`, test default charset 등 기존 +ErrorProne warning이 있지만 focused check는 성공한다. + +### 3.15 Retry wiring이 object identity에 의존한다 + +`OutboundHttpClient.baseline(...)`은 `OutboundHttpResilience`와 `OutboundRetryPolicy`를 별도 +parameter로 받는다. Client는 전달받은 policy에 ThreadLocal call context를 기록하지만, +Resilience4j retry predicate는 `OutboundHttpResilience`를 만들 때 캡처한 policy를 호출한다. + +두 policy instance가 다르면 predicate는 context가 없다고 판단해 retry를 조용히 비활성화한다. +현재 test도 이를 critical wiring constraint로 설명한다. Type system, constructor, composition +validation은 동일 instance를 강제하지 않는다. + +R2 state machine은 ThreadLocal/object-identity coupling을 제거한다. + +- immutable attempt context를 retry decision에 명시적으로 전달; +- retry loop와 decision policy를 한 aggregate가 소유; +- 동일 instance를 수동으로 맞춰야 하는 factory signature 제거; +- composition mismatch characterization test; +- virtual-thread/async boundary에서도 context loss 없음. + +### 3.16 Response-size violation이 retry와 diagnosis를 깨뜨린다 + +`ResponseSizeBoundingInterceptor`가 size exception을 발생시키지만 현재 error mapper는 이를 +stable contract failure로 알지 못한다. Unknown runtime failure fallback이 +`DEPENDENCY_CONNECT_FAILED`이며 retryable이므로 retry-enabled safe method는 같은 oversized +response를 반복 다운로드할 수 있다. + +추가 영향: + +- 최종 size exception이 observer failure path 밖에서 발생해 structured failure log가 빠질 수 있음; +- 큰 4xx/5xx body가 status mapping 전에 size exception으로 바뀌어 원래 status를 잃을 수 있음; +- 현재 size tests는 retry disabled 상태만 검증. + +R2는 status/header를 먼저 분기하고 wire/decoded bound failure를 +`RESPONSE_TOO_LARGE`/`DECOMPRESSION_LIMIT_EXCEEDED`로 매핑한다. 둘은 default no-retry, +circuit-breaker ignore이며 logical failure observation을 정확히 한 번 남긴다. + +## 4. 범위와 명시적 비범위 + +### 4.1 전체 production capability 설계 범위 + +- synchronous/virtual-thread-friendly request-response capability; +- JSON과 bounded binary buffered response; +- bounded streaming download callback; +- bounded/reopenable streaming upload callback; +- named destination와 typed operation catalog; +- relative path template와 typed query/header mapping; +- per-destination Apache HttpComponents classic engine; +- explicit HTTP/1.1 connection pool; +- monotonic total deadline와 active cancellation; +- retry, retry budget, physical-attempt circuit breaker; +- logical admission과 attempt bulkhead; +- optional local outbound quota limiter; +- safe/idempotent/idempotency-key/non-retryable operation; +- unknown mutation outcome와 reconciliation hook; +- redirect disabled default와 future-card용 exact allowlisted state-machine; +- DNS/address/SSRF validation; +- HTTPS/TLS hostname verification, custom trust와 mTLS; +- static bearer/API key/OAuth2 client credentials seams; HTTP Basic은 future-card-only; +- proxy allowlist와 proxy-auth profile; +- content type/encoding/header/body bounds; +- OTel/Micrometer observation ownership; +- typed configuration, activation, lifecycle, health; +- real TCP/TLS/DNS/proxy/failure integration tests; +- readiness card와 runbook. + +위 목록은 이 문서가 설계하는 전체 surface이지 한 번에 R2로 승인되는 minimum profile이 아니다. +R2는 derived selected card와 exact full effective profile tuple 범위에서만 주장한다. + +### 4.2 Minimum R2 static baseline + +최소 ACTIVE R2 profile은 `httpclient-static-buffered` 하나와 그 exact compatibility profile만 +선택하고 다음으로 제한한다. + +- fixed registered destination + relative route; +- synchronous H1; +- bodiless 또는 bounded JSON buffered request/response; +- `SAFE_READ`; +- server TLS/hostname verification; +- auth `none`; +- direct-only, redirect/cookie/engine-hidden-retry disabled; +- finite admission/pool/body/deadline와 hard cancellation; +- safe-read retry budget와 physical-attempt circuit breaker; +- OTel sanitizer, lifecycle, readiness와 runbook. + +다음은 minimum profile 밖이다. + +- `IDEMPOTENT_MUTATION`/`KEYED_MUTATION` -> `httpclient-idempotent-mutation`; +- `NON_RETRYABLE_MUTATION` -> `httpclient-non-retryable-mutation`; +- streaming download/upload -> 각각의 streaming card; +- mTLS, OAuth2 client credentials, required proxy, HTTP/2, untrusted fetch -> 각각의 card. + +Bounded binary, custom server trust, API key와 static bearer는 새 card ID를 만들지 않고 +`httpclient-static-buffered`의 conditional effective mode로 지원할 수 있다. 단 profile +compatibility registry에 provider/protocol/request·response body/TLS/auth/proxy/redirect/ +operation-semantics 전체 tuple이 exact entry로 존재하고 mode-specific 및 cross-mode +HSEC/HRES/HOBS/HCMP scenario가 모두 포함된 경우에만 활성화한다. 기본 card 시나리오나 축별 +지원 합집합만으로 이를 지원했다고 간주하지 않는다. + +Redirect-follow, request-signature와 HTTP Basic은 현재 canonical card set에서 지원하지 않는다. +관련 state-machine 설계는 future card를 위한 것이며, mode가 설정되면 card 등록 전에는 +startup/readiness가 실패한다. “disabled by default”는 켤 수 있다는 의미가 아니다. + +### 4.3 별도 optional capability로 열어둘 항목 + +- HTTP Service Interface proxy; +- conditional GET/ETag; +- RFC-compliant HTTP cache; +- HTTP/2 multiplexed engine; +- reactive `WebClient` engine; +- OAuth2 token exchange/JWT bearer; +- HTTP message signatures; +- resumable/range download; +- multipart/form-data upload; +- webhook callback registration; +- controlled dynamic public egress through an egress proxy; +- service discovery/load-balancer integration; +- client-side hedging for safe reads; +- response streaming to file/objectstorage workflow; +- provider-specific rate-limit header interpretation. + +Optional 항목은 같은 raw API에 boolean을 추가하지 않는다. 요구 guarantee, dependency, +concurrency model이 다르면 별도 readiness card와 provider profile을 갖는다. + +### 4.4 이번 범위에서 제외 + +- controller/inbound DTO와 HTTP server behavior; +- WebSocket, SSE long-lived subscription, gRPC; +- business saga/compensation; +- business-level batch orchestration; +- external API별 domain DTO와 mapping; +- arbitrary user URL fetcher; +- browser cookie/session emulation; +- transparent application-wide retry annotation/AOP; +- distributed transaction; +- service mesh/egress gateway 자체의 설치; +- synthetic benchmark 수치; +- HTTP/3 production baseline. + +사용자가 제공한 URL을 fetch해야 하는 feature는 일반 destination client가 아니다. Egress proxy, +quarantine, content scan, strict public-address policy와 별도 threat model을 가진 capability로 +설계한다. + +## 5. HARD invariants + +다음은 구현 편의를 위해 낮출 수 없다. + +1. `domain-core`와 `application-core`에 Spring HTTP, Apache/JDK client, Resilience4j, + Micrometer/OTel, URI transport 타입을 노출하지 않는다. +2. Application use case는 generic `OutboundHttpClient`나 + `execute(method, url, body)`에 의존하지 않는다. +3. Destination ID와 operation ID는 bounded registry 값이며 caller 입력으로 동적 생성하지 않는다. +4. Base URI, host, port, proxy, credential, TLS bundle은 application command에 포함하지 않는다. +5. Normal operation은 relative path template만 사용하며 absolute URI override를 거부한다. +6. Caller input을 raw path/query/header 문자열 연결에 사용하지 않는다. +7. `Authorization`, `Cookie`, proxy credential, API key를 inbound request에서 자동 전달하지 않는다. +8. Redirect는 default disabled다. Enabled hop마다 destination/IP/credential policy를 다시 + 검증한다. +9. HTTPS production destination에서 trust-all, hostname verification disable, + `NoopHostnameVerifier`를 허용하지 않는다. +10. Plain HTTP는 explicit local/test 또는 승인된 private-network exception 없이는 prod에서 + 거부한다. +11. 하나의 total deadline이 admission, pool wait, attempt, backoff와 body consumption 전체를 + 포함한다. +12. Deadline expiry는 flag만 기록하지 않고 transport task와 response body를 적극 취소/close한다. +13. Wall clock을 elapsed deadline 계산에 사용하지 않는다. +14. Method만으로 retry를 허용하지 않는다. +15. Request body를 재생할 수 없으면 automatic retry를 허용하지 않는다. +16. Non-idempotent mutation은 transmission 가능성이 생긴 뒤 upstream이 문서화한 + idempotency/reconciliation 계약 없이 replay하지 않는다. Exact `NOT_SENT`가 증명된 + pre-send restart는 replay가 아니며 별도 bounded restart policy/budget에서만 허용한다. +17. Mutation request가 전송된 뒤 response를 잃은 경우 ordinary timeout으로 축소하지 않는다. +18. `INDETERMINATE` mutation을 새 operation ID/key로 blind retry하지 않는다. +19. Underlying engine의 hidden automatic retry와 redirect는 끈다. +20. Circuit breaker metric이 physical attempt 정책이라고 문서화되면 실제 physical attempt를 + 개별 집계한다. +21. Retry backoff 중 physical-attempt bulkhead permit과 connection을 보유하지 않는다. +22. Virtual thread는 bulkhead나 connection pool을 무한대로 만들어도 된다는 근거가 아니다. +23. Pool wait와 network connect timeout을 같은 error로 합치지 않는다. +24. Streaming은 무제한 경로가 아니다. +25. Wire bytes와 decoded bytes의 limit을 구분한다. +26. Status와 headers를 검증하기 전에 response body를 consumer에 넘기지 않는다. +27. Response body/stream은 모든 success, failure, cancellation 경로에서 정확히 한 번 close한다. +28. Error response body도 작은 별도 cap 안에서만 선택적으로 decode하며 log에 남기지 않는다. +29. Content type, charset, content encoding과 JSON constraint mismatch를 ordinary 5xx로 숨기지 + 않는다. +30. Metric tag와 span name에 raw path, query, user ID, tenant ID, idempotency key, token, + payload를 사용하지 않는다. +31. Trace propagation은 실제 tracer/instrumentation 하나가 소유한다. +32. Baggage는 destination별 explicit allowlist가 없으면 전파하지 않는다. +33. Liveness가 외부 dependency 상태에 의존하지 않는다. +34. Readiness가 모든 pod를 동시에 eject해 outage를 증폭하지 않도록 dependency impact를 + 명시한다. +35. No binding이면 pool, thread/executor, evictor, DNS lookup, token refresh와 health probe가 + 생성되지 않는다. +36. Legacy global settings와 canonical binding이 함께 있거나 충돌하면 startup을 실패시킨다. +37. Engine/provider가 요구 보장을 증명하지 못하면 더 약한 guarantee로 조용히 downgrade하지 + 않는다. +38. Application-level fallback/compensation은 HTTP kernel 안에 넣지 않는다. +39. Adapter는 application/domain DTO를 wire format으로 그대로 serialize하지 않는다. +40. R2 label은 real-network, TLS, pool, cancellation, security failure evidence 없이 부여하지 + 않는다. + +## 6. 대안 검토 + +### A. 현재 `OutboundHttpClient`에 옵션만 계속 추가 + +장점: + +- 변경량이 작다. +- 기존 tests를 재사용하기 쉽다. + +문제: + +- raw method/URI/body/class API가 더 커진다. +- application semantic port와 transport kernel이 분리되지 않는다. +- destination/operation policy를 runtime argument로 넘기게 된다. +- replayability, unknown outcome, body lifecycle을 표현하기 어렵다. +- boolean 조합이 잘못된 상태를 만들 수 있다. + +선택하지 않는다. 현재 API는 migration facade로만 유지한다. + +### B. Application에 하나의 범용 `HttpPort`를 둔다 + +예: + +```java +HttpResponse call(HttpRequest request); +``` + +이는 HTTP method, status, header, URI, JSON과 transport failure를 application에 유출한다. +Use case가 upstream protocol을 직접 알고 anti-corruption adapter가 사라진다. 선택하지 않는다. + +### C. OpenFeign/HTTP Interface annotation을 application port에 직접 붙인다 + +선언적 API는 편리하지만 annotation과 wire DTO가 application boundary를 오염시킨다. Retry, +auth, status mapping이 proxy magic으로 숨을 수도 있다. + +Spring HTTP Service Interface는 허용하되 adapter package의 upstream wire client로만 둔다. +Application port를 별도로 구현한다. + +### D. JDK `HttpClient`를 R2 default로 계속 사용 + +장점: + +- JDK 21 기본 제공; +- dependency가 적다; +- HTTP/2와 async cancellation API; +- immutable/thread-safe client. + +문제: + +- builder API로 per-destination total/per-route pool과 lease timeout을 구성하기 어렵다. +- pool resource와 pending lease observability가 제한된다. +- custom DNS/address admission과 connection lifecycle evidence가 어렵다. +- keep-alive pool 일부가 implementation property에 의존한다. + +JDK engine은 R1 compatibility 또는 제한된 profile로 열어두되 초기 R2 default로 선택하지 않는다. + +### E. Apache HttpComponents 5 classic + Spring `RestClient` + +장점: + +- Spring MVC/imperative baseline과 맞는다. +- total/per-route pool, lease timeout, TTL, idle validation, DNS resolver, TLS, proxy를 제어할 수 + 있다. +- hard cancellation과 connection eviction을 검증할 수 있다. +- broad reactive stack 없이 virtual-thread-friendly blocking facade를 제공할 수 있다. + +단점: + +- total deadline을 `RestClient` timeout 하나로 얻을 수 없다. +- active cancellation을 위한 execution wrapper와 engine-specific evidence가 필요하다. +- HTTP/2는 별도 provider 판단이 필요하다. + +초기 minimum-R2 reference candidate로 선택하되 hard-cancel/card evidence 전에는 R2로 승격하지 +않는다. + +### F. `WebClient` + Reactor Netty를 모든 호출의 default로 사용 + +장점: + +- cancellation/backpressure/HTTP2/pool 설정이 풍부하다. +- streaming에 강하다. + +문제: + +- 현재 imperative/virtual-thread template에 reactive runtime과 context semantics를 강제한다. +- `.block()` facade는 cancellation/context/metrics를 잘못 연결하기 쉽다. +- 모든 use case가 reactive일 필요는 없다. + +Reactive application이나 HTTP/2 streaming 요구에 대한 별도 provider로 열어둔다. + +### G. Apache async engine을 직접 감싼 blocking facade + +가장 강한 cancellation과 HTTP/2 확장 경로를 제공하지만 codec, Spring HTTP Service integration, +error mapping을 더 많이 직접 소유한다. 초기 Phase 1은 Apache classic으로 시작하고, classic +hard-cancel evidence가 요구를 만족하지 못하면 이 provider로 승격한다. + +### H. Resilience를 Spring annotation/AOP로 적용 + +Method annotation은 operation descriptor, body replayability, failure phase와 remaining deadline을 +알기 어렵다. Decorator order도 proxy order에 숨는다. 선택하지 않는다. + +### I. Explicit call state machine에서 Resilience4j primitive를 사용 + +선택한 방식이다. + +- Resilience4j registry/state machine은 재사용한다. +- Retry loop와 ordering은 adapter가 명시적으로 소유한다. +- AOP/annotation은 쓰지 않는다. +- Engine의 automatic retry는 끈다. +- 각 physical attempt의 permission/metric/span을 코드와 test가 검증한다. + +## 7. 목표 아키텍처 + +```mermaid +flowchart LR + UC[Application use case] + PORT[Feature-specific outbound port] + ACL[Upstream anti-corruption adapter] + CAT[Typed operation catalog] + REG[Destination registry] + KERNEL[HTTP execution kernel] + ADMIT[Logical admission] + RETRY[Retry budget and loop] + CB[Physical-attempt circuit breaker] + LIMIT[Attempt bulkhead and local quota] + ENGINE[Apache classic engine] + POOL[Destination-isolated pool] + DNS[Validated DNS/address resolver] + TLS[TLS/mTLS/auth/proxy] + UP[External dependency] + OBS[Observation/error/lifecycle] + + UC --> PORT + ACL --> PORT + ACL --> CAT + ACL --> KERNEL + KERNEL --> REG + KERNEL --> ADMIT + ADMIT --> RETRY + RETRY --> LIMIT + LIMIT --> CB + CB --> ENGINE + ENGINE --> POOL + POOL --> DNS + ENGINE --> TLS + DNS --> UP + TLS --> UP + KERNEL --> OBS +``` + +Dependency direction: + +```text +production fork: + application-core + owns feature-specific port and application outcome + + adapter-outbound-httpclient + implements that production port + owns external wire DTO, HTTP interface, operation descriptor, + execution kernel, engine, resilience, auth and mapping + +skeleton sample: + sample-portfolio + owns RepoStatsPort, use case, wire mapper and sample-local RepoStatsHttpAdapter + consumes the HTTP leaf's adapter-consumer SPI after an explicit registry edge + + adapter-outbound-httpclient + never depends on sample-portfolio + +adapter-outbound-support + may provide framework-neutral outbound correlation/observation helpers + +app-bootstrap + binds selected destinations/providers and validates descriptors +``` + +`adapter-outbound-httpclient`가 다른 outbound adapter를 직접 호출하지 않는다. OAuth token, +distributed rate quota, secret manager 등이 별도 capability여도 adapter-to-adapter edge를 +추가하지 않는다. 필요한 framework-neutral port나 bootstrap composition을 설계하고 registry +승인 뒤 연결한다. + +## 8. 모듈과 계층 소유권 + +### 8.1 `domain-core` + +허용: + +- 외부 서비스 결과가 실제 domain concept이면 domain value; +- upstream과 무관한 invariant. + +금지: + +- HTTP status/method/header; +- URI; +- timeout/retry; +- JSON/wire DTO; +- provider ID. + +### 8.2 `application-core` + +소유: + +- `FraudScreeningPort`, `TaxQuotePort`, `PartnerCatalogPort` 같은 semantic port; +- application request/result; +- domain/application-level unavailable/indeterminate outcome; +- use-case deadline 전달을 위한 framework-neutral `CallBudget`가 필요하다면 그 contract; +- compensation/reconciliation use case. + +금지: + +- `OutboundHttpClient`; +- Spring `HttpMethod`, `HttpHeaders`, `ResponseEntity`; +- Apache/JDK client exception; +- OAuth/SSL bundle; +- raw URL. + +예: + +```java +public interface PartnerCatalogPort { + CatalogLookupResult find(ProductReference reference, CallBudget budget); +} +``` + +`CatalogLookupResult`의 `NotFound`는 upstream 404 자체가 아니라 application이 정의한 absence다. + +### 8.3 `adapter-outbound-httpclient` + +소유: + +- production fork의 upstream별 `application-core` port implementation; +- wire request/response DTO와 mapper; +- operation catalog; +- destination binding; +- path/query/header encoding; +- engine SPI와 Apache provider; +- timeout/cancellation; +- resilience; +- TLS/auth/proxy/DNS; +- status/error mapping; +- observability/lifecycle; +- provider tests. + +HTTP Service annotation interface를 쓴다면 이 leaf 안에 둔다. + +Template sample처럼 feature port가 `sample-portfolio`에 격리된 경우 HTTP leaf가 sample port를 +구현하지 않는다. 대신 §10.5의 bounded adapter-consumer SPI를 제공하고 sample-local outbound +adapter가 이를 사용한다. + +### 8.4 `shared-contract` + +정말 여러 runtime leaf가 동일하게 소비할 때만 다음과 같은 value-only operational contract를 +둘 수 있다. + +- bounded capability descriptor; +- generic readiness level; +- low-cardinality dependency outcome vocabulary; +- framework-neutral deadline carrier. + +HTTP operation, URL, method, credential을 skeleton-wide contract로 올리지 않는다. + +### 8.5 `adapter-outbound-support` + +유지 가능한 책임: + +- correlation value 추출; +- 공통 clock abstraction; +- generic bounded metric naming helper; +- secret-safe diagnostic formatter. + +HTTP-specific retry/status/pool/DNS/TLS 정책은 httpclient leaf가 소유한다. + +### 8.6 `app-bootstrap` + +소유: + +- canonical activation; +- typed configuration binding; +- selected provider/destination validation; +- SSL bundle와 secret reference resolution; +- capability descriptor aggregation; +- required readiness policy; +- lifecycle ordering. + +Business API operation이나 mapping을 bootstrap에 넣지 않는다. + +### 8.7 `sample-portfolio` + +Production leaf가 sample을 의존하지 않는다. 현재 sample은 `RepoStatsPort`, +`GetRepoStatsUseCase`, fixture `RepoStatsPortClient`를 이미 가진다. + +실제 HTTP reference consumer로 전환할 때: + +- `sample-portfolio -> adapter-outbound-httpclient` edge를 + `src/config/architecture/modules.json`에 명시적으로 추가; +- sample-local `RepoStatsHttpAdapter`가 `RepoStatsPort`를 구현; +- adapter-consumer SPI는 sample adapter package에서만 사용; +- `repoUrl` raw string을 `RepositoryCoordinates(hostProfile, owner, repository)` 같은 validated + application value로 변경; +- inbound가 허용된 Git provider URL을 parse하되 application/adaptor로 absolute URL을 전달하지 + 않음; +- registered fixed destination + relative route로 재구성; +- sample-off production build에서는 이 consumer/binding이 없어도 됨; +- sample contract는 provider/card implementation evidence와 deployment ACTIVE readiness를 + 대신하지 않음. + +Registry edge 변경 전에는 sample에서 HTTP leaf type을 import하지 않는다. + +### 8.8 두 consumer shape의 선택 + +```text +실제 fork production: + application-core feature port + <- adapter-outbound-httpclient의 feature adapter + +template sample fixture: + sample-local feature port + <- sample-local outbound adapter + -> adapter-outbound-httpclient adapter-consumer SPI +``` + +새 business concept를 production `application-core`에 demo 목적으로 추가하지 않는다. 반대로 +HTTP leaf가 sample business type을 import하지 않는다. 어느 shape든 controller/use case가 generic +HTTP kernel을 직접 호출하는 것은 금지한다. + +## 9. Identity와 vocabulary + +### 9.1 Destination ID + +`HttpDestinationId`는 bounded configuration/registry key다. + +예: + +```text +partner-catalog +fraud-screening +tax-service +github-api +``` + +규칙: + +- lowercase kebab case; +- committed registry와 typed binding에 존재; +- metric/span/log tag로 사용 가능; +- tenant/user/request에서 동적 생성 금지; +- host name과 동일할 필요 없음; +- credential/TLS/pool isolation 단위. + +### 9.2 Operation ID + +`HttpOperationId`는 한 upstream API operation의 안정적인 low-cardinality ID다. + +```text +partner-catalog.get-product.v1 +fraud-screening.evaluate.v2 +tax-service.create-quote.v1 +``` + +Operation ID는 다음 정책의 join key다. + +- method/path template; +- success/error status; +- body codec; +- retry safety; +- idempotency-key; +- deadline; +- size; +- observability; +- readiness test. + +Endpoint path나 Java method name에서 runtime 추론하지 않는다. + +### 9.3 Logical call ID + +한 application port invocation 안에서 retry/redirect/auth-refresh를 묶는 opaque attempt-group +identity다. + +- log/trace correlation용; +- metric tag로 사용 금지; +- idempotency key와 다름; +- application business ID와 다름; +- 한 logical call의 모든 physical attempt에서 동일. + +### 9.4 HTTP request attempt와 공통 amplification budget + +`HttpRequestAttempt`는 origin으로 wire request 하나를 시작하려는 단위다. 0부터 증가하는 +`physicalAttemptOrdinal`은 다음을 모두 같은 연속 번호로 센다. + +- initial request; +- ordinary retry; +- confirmed-`NOT_SENT` pre-send restart; +- same-intent/reconciliation 뒤 replay; +- 안전하게 허용한 redirect hop; +- 401 credential refresh 뒤 auth replay; +- provider가 processing evidence로 증명하고 kernel이 새 요청으로 승인한 protocol restart. + +사유별 counter는 별도로 유지한다. + +```text +ordinaryRetryCount +preSendRestartCount +authReplayCount +redirectHopCount +sameIntentReplayCount +confirmedNotProcessedRestartCount +``` + +그러나 각 counter/budget의 합이 전체 증폭 상한을 우회해서는 안 된다. + +```text +maxProtectedPhysicalAttemptsPerLogicalCall +maxNestedCredentialRequestsPerLogicalCall +maxReconciliationRequestsPerLogicalCall +maxProxyConnectRequestsPerRootCall +maxRevocationHttpRequestsPerRootCall +maxTotalHttpRequestAttemptsPerRootCall +``` + +Protected destination의 모든 origin request start는 첫 번째와 마지막 shared token을 원자적으로 +소비한다. Token endpoint, in-call reconciliation, HTTP CONNECT proxy와 named HTTP OCSP/CRL lookup의 +각 실제 HTTP request도 자신의 bounded counter와 같은 root-call total token을 소비한다. +Redirect/auth refresh/retry가 중첩돼도 곱셈 증폭되지 않는다. DNS query와 A/AAAA별 TCP connect는 +HTTP request가 아니므로 이 token 대신 resolver/per-address connect cap과 같은 total deadline을 +사용한다. Deferred reconciliation use case가 나중에 독립 호출로 시작되면 새 root-call budget을 +갖지만 같은 operation identity와 별도 scheduler quota를 유지한다. + +`maxAttempts`라는 모호한 이름은 canonical model에서 사용하지 않는다. Operation별 ordinary retry +횟수와 위 physical/root ceiling을 분리한다. Runtime config가 catalog 상한을 낮출 수는 있지만 +증가시킬 수 없다. Protected/root 상한은 finite positive이고 비활성일 수 있는 child 상한은 finite +non-negative이며 다음 cross-field invariant를 만족한다. + +```text +1 <= maxProtectedPhysicalAttemptsPerLogicalCall +maxNestedCredentialRequestsPerLogicalCall >= 0 +maxReconciliationRequestsPerLogicalCall >= 0 +maxProxyConnectRequestsPerRootCall >= 0 +maxRevocationHttpRequestsPerRootCall >= 0 +maxTotalHttpRequestAttemptsPerRootCall >= minimumRequiredRootHttpAttempts(effective profile) +``` + +`minimumRequiredRootHttpAttempts`는 exact profile의 cold-path scenario마다 계산한다. 최소 initial +protected request, OAuth cache miss/401 refresh, required proxy CONNECT, hard-fail revocation lookup와 +advertised auth replay에 필요한 protected request를 포함한다. 예를 들어 one-401 OAuth replay를 +지원하면 protected ceiling은 최소 2이고 해당 scenario의 token refresh + 두 protected requests가 +root ceiling 안에 들어야 한다. Root ceiling이 reason-specific maxima의 합보다 작을 수는 있지만, +그 때문에 required scenario 하나라도 구조적으로 실행 불가능하면 startup/qualification이 실패한다. + +Protected origin 밖에서 실제 HTTP request를 만드는 child path는 공통 authorization protocol을 +사용한다. + +```text +NestedHttpRequestKind = OAUTH_TOKEN | RECONCILIATION | PROXY_CONNECT | REVOCATION +NestedHttpAuthorizationLease( + kind, + rootCallId, + childProfileFingerprint, + childAttemptOrdinal, + state = AUTHORIZED | BOUND_TO_ENGINE | ABORTED +) +``` + +`NestedHttpAuthorizationBroker`는 parent root context가 이 lease만 발급하는 최소 capability이며 +provider/child adapter에 mutable counter나 refill API를 노출하지 않는다. Broker 생성 시 exact +dependency DAG에서 다음 immutable edge set을 캡처한다. + +```text +AllowedChildEdge( + parentProfileFingerprint, + kind, + childProfileFingerprint, + childOperationId, + childAuthorityPolicyFingerprint +) +``` + +Caller가 전달한 kind/profile/operation edge가 이 set과 exact match하지 않거나 다른 root의 broker/ +lease를 재사용하면 child/root token 소비 전에 거절한다. Lease bind 시 provider가 제출한 resolved +child profile, operation, scheme/authority-policy fingerprint도 lease edge와 byte-for-byte 일치해야 +한다. 따라서 broker를 generic registered-child/authority oracle로 사용할 수 없다. +`tryAuthorizeNestedHttpRequest`는 deadline/cancellation과 해당 child cap의 순수 availability를 먼저 +검사하고, exact child counter token을 원자적으로 한 번 소비해 lease를 만든다. 실제 wire start +직전에는 같은 root-call total HTTP token 하나를 uncommitted reserve하고 cancellation과 engine +handoff를 race한다. Handoff가 이기면 lease/root token을 bind/commit하고, 그 전 실패면 root token은 +반납하되 child reason token은 amplification churn을 막기 위해 되살리지 않고 lease를 `ABORTED`로 +exactly once 닫는다. Cache hit, 기존 CONNECT tunnel 재사용처럼 HTTP request가 발생하지 않는 +경로는 lease/token을 소비하지 않는다. Provider가 이 broker를 호출하지 않고 child HTTP request를 +시작할 수 있으면 해당 profile은 release-eligible이 아니다. + +`TransportConnectAttempt`는 A/AAAA address candidate 하나에 대한 connect 시도이며 +`HttpRequestAttempt`와 다르다. Request byte 전 address failover는 connect event/metric일 뿐 +`http.request.resend_count`나 physical attempt ordinal을 증가시키지 않는다. First request byte 뒤 +재연결은 새 `HttpRequestAttempt`이며 operation state machine 승인이 필요하다. + +Engine autonomous resend가 start 전 shared token/deadline/body/CB/bulkhead/span gate를 호출할 수 +없으면 hidden resend를 비활성화해야 하며 해당 provider/profile은 release-eligible이 아니다. +사후 관측만으로 공통 상한을 지켰다고 주장하지 않는다. + +### 9.5 Operation attempt ID와 idempotency key + +Mutation은 application workflow가 생성해 재시도/재시작에도 보존하는 안정적인 +`OperationAttemptId`를 가질 수 있다. Adapter는 이를 upstream 형식의 idempotency key로 encode한다. + +규칙: + +- 첫 network call 전에 생성; +- response loss 뒤에도 caller가 보유; +- payload fingerprint와 결합; +- 다른 intent에 재사용 금지; +- log/metric에 raw value 금지; +- provider가 expiry와 replay semantics를 문서화한 경우에만 retry 근거. + +### 9.6 Policy revision + +Destination와 operation policy에는 stable ID와 revision/digest가 있다. + +```text +destination profile: partner-catalog-r2 / revision 4 +operation profile: partner-catalog.get-product.v1 / revision 2 +``` + +같은 revision의 canonical digest가 배포 사이에서 달라지면 startup을 실패시킨다. Rolling +deployment에서 retry semantics가 조용히 변하는 것을 막는다. + +## 10. Application semantic port와 adapter boundary + +### 10.1 Feature-specific port 원칙 + +Application port는 upstream HTTP API를 복제하지 않고 use case가 필요한 의미만 노출한다. + +좋은 예: + +```java +public interface FraudScreeningPort { + ScreeningDecision evaluate(ScreeningSubject subject, CallBudget budget); +} +``` + +나쁜 예: + +```java +public interface HttpPort { + HttpResponse execute(String method, String url, Map headers, Object body); +} +``` + +### 10.2 Wire DTO 격리 + +Adapter 흐름: + +```text +application request + -> upstream request mapper + -> wire DTO + -> registered HTTP operation + -> wire response DTO + -> schema/semantic validation + -> application result +``` + +Wire DTO는: + +- upstream field name/version/null semantics를 소유; +- Jackson annotation을 가질 수 있음; +- application/domain package로 반환하지 않음; +- tolerant read와 required semantic validation을 분리; +- raw problem detail/error body를 application에 노출하지 않음. + +### 10.3 Application failure shape + +Application port마다 의미 있는 결과를 선택한다. + +예: + +```text +Found +NotFound +RejectedByPartner +TemporarilyUnavailable +Indeterminate(operationAttemptId) +``` + +모든 port가 같은 generic exception을 강제로 사용하지 않는다. Kernel failure를 adapter가 +operation 의미에 맞게 매핑한다. + +### 10.4 Call budget + +Use case 전체에 이미 deadline이 있으면 application은 framework-neutral absolute budget을 +전달할 수 있다. + +```java +public record CallBudget(long monotonicDeadlineNanos) { +} +``` + +실제 contract는 다음을 만족해야 한다. + +- `System.nanoTime()`과 같은 monotonic time domain; +- wall-clock timestamp로 serialize하지 않음; +- destination policy cap과 `min`으로 결합; +- 이미 만료되면 network side effect 없이 reject; +- child call이 parent보다 긴 budget을 만들 수 없음. + +`Duration timeout`만 매번 전달하면 nested call이 호출 시점마다 새 budget을 받아 상위 deadline을 +넘길 수 있으므로 absolute budget을 선호한다. + +### 10.5 Adapter-consumer SPI와 internal kernel + +Kernel은 application/controller에 공개하지 않는다. 다만 sample-local outbound adapter나 future +domain-specific outbound leaf가 capability를 재사용할 수 있도록 최소 adapter-consumer SPI를 +공개한다. + +개념 예: + +```java +interface RegisteredHttpOperationInvoker { + HttpCallResult execute( + HttpOperation operation, + Req request, + CallContext context); +} +``` + +`HttpOperation`은 registry와 fingerprint가 일치하는 immutable descriptor다. Caller는 method, +absolute URL, raw header, credential, retry boolean을 runtime에 제공하지 않는다. + +Boundary: + +- application/domain/inbound package의 SPI import는 ArchUnit으로 금지; +- adapter/sample outbound package만 사용; +- operation 등록은 startup 전 완료하고 runtime dynamic registration 금지; +- descriptor/codec/wire DTO는 consumer adapter 소유 가능; +- kernel provider/Apache/Spring type은 SPI에 노출하지 않음; +- public-path snapshot으로 accidental generic SDK surface 확장을 검출. + +HTTP leaf 안에서 provider와 resilience를 다루는 `HttpTransportEngine`은 계속 internal이다. + +### 10.6 HTTP Service Interface 사용 + +Spring HTTP Service Interface는 다음 조건에서 adapter-internal wire client로 사용할 수 있다. + +- interface와 annotation이 adapter package에 위치; +- feature-specific application port를 별도로 구현; +- group/destination이 canonical registry와 1:1로 검증; +- underlying `RestClient`가 동일 kernel의 engine, auth, bounds, observation을 사용; +- proxy가 kernel의 retry/deadline을 우회하지 않음; +- method metadata가 operation catalog와 build-time 대조됨; +- return type이 wire DTO이며 domain/application DTO가 아님. + +단순 `@ImportHttpServices` classpath scan으로 새 client를 자동 활성화하지 않는다. + +## 11. Typed operation catalog + +### 11.1 Catalog가 필요한 이유 + +동일 destination 안에서도 operation마다 안전성이 다르다. + +```text +GET /products/{id} +POST /quotes +POST /payments/{id}/capture +GET /exports/{id}/content +DELETE /sessions/{id} +``` + +Destination global retry boolean로 이 차이를 표현할 수 없다. Operation catalog는 runtime +request가 정책을 선택하는 것을 막고 code review 가능한 안전 계약을 제공한다. + +### 11.2 Operation descriptor + +개념적 `HttpOperation` 필드: + +| 필드 | 의미 | +| --- | --- | +| `operationId` | stable low-cardinality ID | +| `destinationId` | exact destination binding | +| `policyRevision` | rolling compatibility revision | +| `method` | fixed HTTP method | +| `routeTemplate` | low-cardinality relative template | +| `semantics` | `SAFE_READ`, `IDEMPOTENT_MUTATION`, `KEYED_MUTATION`, `NON_RETRYABLE_MUTATION` | +| `requestMode` | `NONE`, `BUFFERED`, `REOPENABLE_STREAM`, `SINGLE_USE_STREAM` | +| `responseMode` | `BODILESS`, `BUFFERED`, `STREAM_CALLBACK` | +| `requestCodecId` | wire encoder | +| `responseCodecId` | wire decoder | +| `successContractId` | accepted status/media/schema | +| `errorContractId` | operation-specific status mapping | +| `deadlineProfileId` | phase/total budget profile | +| `retryProfileId` | retry decision/backoff/budget | +| `resilienceGroupId` | CB/bulkhead state-sharing group | +| `authProfileId` | adapter-owned credential profile | +| `egressPolicyId` | URI/address/redirect/proxy policy | +| `observabilityProfileId` | route template and privacy policy | +| `readinessCardIds` | evidence required before R2 | + +Runtime caller가 이 필드를 override하지 않는다. + +### 11.3 Executable definition과 review registry + +다음 이중 구조를 사용한다. + +1. Adapter Java code의 immutable descriptor가 executable behavior를 소유한다. +2. `docs/registries/http-operations.yaml`은 operation identity, revision, safety class, + readiness evidence의 review registry다. +3. Build test가 registry와 runtime descriptor를 one-to-one 대조하고 canonical fingerprint를 + 비교한다. +4. Java code에만 존재하거나 registry에만 존재하는 operation은 실패한다. +5. YAML로 class name을 reflection load하거나 arbitrary expression을 실행하지 않는다. + +Template 자체에는 domain operation을 강제하지 않는다. Schema와 fixture operation만 제공하고 +fork가 실제 operation을 등록한다. + +Illustrative registry: + +```yaml +schema_version: 1 +operations: + - id: partner-catalog.get-product.v1 + destination: partner-catalog + policy_revision: 2 + method: GET + route_template: /v1/products/{productRef} + semantics: SAFE_READ + request_mode: NONE + response_mode: BUFFERED + retry_profile: safe-read + resilience_group: partner-catalog-read + readiness_cards: + - httpclient-static-buffered +``` + +### 11.4 Descriptor validation + +Startup/build-time invariant: + +- operation/destination ID grammar와 uniqueness; +- route가 relative이며 scheme/authority/userinfo/fragment 없음; +- path variable declaration과 mapper가 정확히 일치; +- query/header key가 allowlist에 존재; +- `KEYED_MUTATION`이면 idempotency key encoder, stable attempt ID, payload fingerprint, + provider replay window와 reconciliation operation이 모두 존재; +- `SINGLE_USE_SOURCE`이면 `maxProtectedPhysicalAttemptsPerLogicalCall=1`; +- `NON_RETRYABLE_MUTATION`이면 default `maxProtectedPhysicalAttemptsPerLogicalCall=1`, redirect/auth replay 금지, transmission + evidence-to-receipt mapping 필수; +- non-retryable pre-send restart를 opt-in하면 `maxProtectedPhysicalAttemptsPerLogicalCall<=2`, buffered/reopenable body, + exact `NOT_SENT` evidence와 별도 restart policy/budget/scenario 필수; +- retryable operation이면 body가 absent/buffered/reopenable; +- streaming response에는 status validator, body cap, cancellation-safe callback contract가 존재; +- success/error status set이 겹치지 않음; +- response codec이 accepted media type마다 존재; +- auth profile과 redirect cross-origin policy가 충돌하지 않음; +- deadline phase 합이 total을 강제로 결정한다는 잘못된 계산을 하지 않음; +- resilience group/card가 registry에 존재; +- readiness card가 operation 요구 feature를 모두 덮음. + +### 11.5 Configuration override 제한 + +Deployment configuration은 다음을 더 보수적으로 만들 수 있다. + +- 더 짧은 timeout; +- 더 작은 body/header limit; +- 더 적은 retry; +- 더 낮은 concurrency; +- redirect disable; +- HTTP/2에서 HTTP/1.1로 제한; +- optional dependency를 disabled. + +다음은 configuration만으로 넓힐 수 없다. + +- method 또는 route; +- safe/idempotent classification; +- accepted destination/redirect host; +- retryable status/exception; +- body replayability; +- auth header 종류; +- media type; +- private CIDR access; +- unknown outcome을 success로 변경. + +안전성을 넓히는 변경은 code, registry revision, tests와 review가 필요하다. + +## 12. Request target와 URI construction + +### 12.1 Base URI + +Destination base URI는 startup에 parse/normalize한다. + +Required: + +- absolute URI; +- default `https`; +- exact lowercase ASCII/IDNA host; +- explicit 또는 scheme default port; +- optional fixed base path; +- empty userinfo; +- empty query/fragment; +- no ambiguous backslash/control/whitespace; +- normalized dot segment 없음; +- allowed scheme/host/port policy와 일치. + +Host 비교는 display Unicode가 아니라 canonical ASCII form을 사용한다. IP literal은 canonical +binary address로 비교한다. IPv4-in-IPv6 mapped address와 zone ID도 명시적으로 처리한다. + +### 12.2 Relative route only + +Normal operation은 catalog의 relative route template만 사용한다. + +거부: + +```text +https://other.example/path +//other.example/path +file:///etc/passwd +gopher://... +data:... +../admin +%2e%2e/admin +\other +``` + +Base URI resolution 전에 raw/decoded 두 표현의 ambiguity를 검사한다. Decode 후 다시 decode하는 +double-encoding을 허용하지 않는다. + +### 12.3 Path variable + +각 variable은 한 segment value다. + +- URI component encoder로 정확히 한 번 encode; +- `/`, `\`, NUL, control, dot segment 거부; +- 길이와 character profile 제한; +- Unicode normalization policy 고정; +- pre-encoded input 금지; +- empty 허용 여부를 operation이 선언; +- path remainder/wildcard는 별도 typed value와 stricter test 없이는 금지. + +String concatenation으로 URI를 만들지 않는다. + +### 12.4 Query + +Operation이 key와 multiplicity를 선언한다. + +- key는 caller가 선택하지 않음; +- value는 component encoding; +- list ordering/canonicalization 명시; +- duplicate 허용 여부; +- blank/null/absent 차이; +- page size/range/total query length bound; +- secret/token/PII query parameter 금지; +- signature provider가 요구하면 canonical order와 exact encoding golden test. + +Query는 log, metric tag, span name에 기록하지 않는다. + +### 12.5 Dynamic target exception + +다음은 일반 URI seam으로 허용하지 않는다. + +- user supplied webhook URL; +- arbitrary avatar/document fetch; +- upstream이 응답한 presigned URL; +- pagination `next` absolute link. + +필요하면 별도 operation type을 만든다. + +- trusted issuer/source 검증; +- allowed scheme/host suffix가 아닌 exact policy; +- resolved IP validation; +- redirect 재검증; +- credential 제거; +- size/type/scan; +- egress proxy; +- bounded lifetime; +- no internal address. + +`next` link는 가능한 경우 opaque cursor만 추출해 known route를 재구성한다. + +## 13. Header, cookie와 metadata policy + +### 13.1 Header ownership + +세 그룹으로 나눈다. + +1. Engine-owned: + - `Host`/`:authority`; + - `Content-Length`, `Transfer-Encoding`; + - connection/protocol headers; + - proxy authorization. +2. Infrastructure-owned: + - `Authorization`/API key; + - trace context; + - sanitized `User-Agent`; + - `Idempotency-Key`; + - conditional/version headers declared by operation. +3. Operation-owned typed business metadata: + - fixed `Accept`, `Content-Type`; + - provider-documented correlation/reference; + - bounded locale or version enum. + +Application caller에게 arbitrary `Map`을 주지 않는다. + +### 13.2 Forbidden forwarding + +Inbound에서 자동 forward 금지: + +```text +Authorization +Proxy-Authorization +Cookie +Set-Cookie +X-Forwarded-* +Forwarded +Host +Content-Length +Transfer-Encoding +Connection +Upgrade +TE +Trailer +Keep-Alive +``` + +사용자 bearer token의 on-behalf-of 전달이 비즈니스 요구라면 별도 credential exchange/delegation +profile을 사용한다. Raw inbound token pass-through를 default로 하지 않는다. + +### 13.3 Header limits + +Request와 response 모두: + +- max field count; +- max single name/value bytes; +- max aggregate bytes; +- duplicate singleton rejection; +- invalid control/obs-fold rejection; +- allowlisted captured response headers; +- trailer allowlist와 aggregate cap; +- header casing에 의미를 두지 않음. + +Underlying engine/JVM global header limit만 믿지 않고 destination policy와 test를 둔다. + +### 13.4 Cookies + +Machine-to-machine default: + +- cookie store disabled; +- `Set-Cookie` 저장/재전송 안 함; +- caller cookie 금지. + +Cookie-required partner가 있으면: + +- destination-exclusive bounded store; +- domain/path/Secure/SameSite policy; +- max cookies/bytes/TTL; +- tenant 간 공유 금지; +- restart persistence 여부; +- secret classification; +- 별도 readiness card. + +Browser session emulation은 baseline이 아니다. + +### 13.5 Baggage와 correlation + +- OTel propagator가 `traceparent`와 `tracestate` 소유; +- `baggage`는 default empty; +- destination별 key allowlist; +- external partner에는 tenant/user/business ID default 금지; +- `X-Request-Id`/`X-Correlation-Id`도 partner contract에 있을 때만 전파; +- inbound value는 syntax/length 검증 후 사용; +- caller-provided trace header override 금지. + +## 14. Response contract와 failure taxonomy + +### 14.1 Kernel result + +Kernel은 raw exception 대신 내부적으로 다음 결과를 만든다. + +```text +Completed +Rejected(HttpFailure) +Indeterminate(HttpMutationUncertainty) +Cancelled(HttpCancellation) +``` + +Adapter가 이를 application-specific result/exception으로 변환한다. + +### 14.2 Completed + +`Completed`는 단순 2xx가 아니다. + +모두 만족해야 한다. + +- operation이 success로 선언한 status; +- framing complete; +- body size 안; +- accepted content type/encoding/charset; +- decode 성공; +- required wire field와 semantic invariant 성공; +- stream callback 정상 종료; +- response close 성공 또는 close failure가 결과 신뢰도에 영향 없음을 증명. + +Status 204/304/HEAD처럼 body가 없어야 하는 response에 unexpected body가 있으면 engine/framing +policy에 따라 discard cap 안에서 닫고 protocol drift를 기록한다. + +### 14.3 Failure stages + +가능하면 engine은 다음 stage evidence를 제공한다. + +```text +ADMISSION +POOL_ACQUIRE +DNS_RESOLUTION +CONNECT +TLS_HANDSHAKE +REQUEST_HEADERS +REQUEST_BODY +RESPONSE_HEADERS +RESPONSE_BODY +DECODE +CALLBACK +``` + +Transmission progress와 observation confidence를 분리한다. + +```text +TransmissionProgress = + NOT_SENT | MAYBE_SENT | SENT | RESPONSE_STARTED | RESPONSE_COMPLETE +ObservationConfidence = + OBSERVED | UNKNOWN_AFTER_HANDOFF +ProjectedTransmissionEvidence = + progress when sufficient OBSERVED evidence exists, otherwise UNKNOWN +``` + +정상 provider의 tracker는 progress에 다음 단조 전이만 허용한다. + +```text +NOT_SENT + -- immediately before the first possible origin-request byte/frame write --> MAYBE_SENT + -- local request headers/body write completed ----------------------------> SENT + -- first valid response headers observed ---------------------------------> RESPONSE_STARTED + -- required response framing/body contract completed ---------------------> RESPONSE_COMPLETE +``` + +첫 write는 HTTP/1.1 request line/header byte, HTTP/2 HEADERS frame 제출처럼 upstream이 request를 +관측할 수 있는 가장 이른 지점이다. Provider는 그 지점 전에 synchronous tracker callback을 +호출해야 한다. Engine ownership transfer 뒤 이 callback을 보장하지 못하거나 start/cancel race의 +증거를 회수하지 못하면 progress의 마지막 observed lower bound는 유지하고 confidence만 +`UNKNOWN_AFTER_HANDOFF`로 단조 전이한다. Progress는 어느 상태도 downgrade하지 않고 cleanup/cancel이 +이를 `NOT_SENT`로 되돌리지 않는다. + +한 physical attempt의 authoritative response/failure event와 cancellation은 atomic terminal CAS로 +정확히 하나만 승리한다. Response winner가 retry/auth/redirect control disposition을 만들 수 있으므로 +항상 logical call terminal을 뜻하지는 않는다. Valid completed/rejection response가 먼저 확정되면 +뒤늦은 cancel이 결과를 덮지 않는다. 반대로 mutation이 cancel/deadline 시점에 `MAYBE_SENT` 이상 +또는 `UNKNOWN`이고 authoritative terminal result가 없으면 반드시 `INDETERMINATE`다. Exact +`NOT_SENT`일 때만 `Cancelled` 또는 reviewed pre-send restart가 가능하다. + +`UNKNOWN_AFTER_HANDOFF`는 progress 순서의 마지막 값이 아니다. 이후 authoritative response +headers/body completion을 직접 관측하면 progress를 `RESPONSE_STARTED|RESPONSE_COMPLETE`로 전진시키고 +그 이후 사실의 confidence를 `OBSERVED`로 기록할 수 있다. 다만 response가 없다는 이유로 unknown +request transmission을 `NOT_SENT|SENT`로 추론하지 않는다. Valid terminal response가 있으면 §16의 +response semantic outcome이 우선하고, 없으면 projected evidence는 계속 `UNKNOWN`이다. + +Provider가 정확히 관측하지 못하면 더 강한 값으로 추측하지 않고 `UNKNOWN`을 사용한다. 이 +linearization callback과 race test를 제공하지 못하는 engine은 non-retryable mutation card를 +통과하지 못한다. + +### 14.4 Stable failure classes + +| Failure | 의미 | Generic retry default | +| --- | --- | --- | +| `CALL_BUDGET_EXHAUSTED` | network 전 또는 중 total deadline 만료 | operation policy | +| `ADMISSION_REJECTED` | logical call queue/budget 초과 | false | +| `POOL_ACQUIRE_TIMEOUT` | connection/stream lease 대기 만료 | safe/replayable만 | +| `LOCAL_RATE_LIMITED` | local egress quota 거부 | Retry-After와 budget에 따름 | +| `CIRCUIT_OPEN` | resilience group open | false inside same call | +| `DNS_FAILED` | name resolution 실패 | safe/replayable + bounded | +| `DESTINATION_ADDRESS_REJECTED` | IP/SSRF policy 위반 | false, security alert | +| `CONNECT_TIMEOUT` | socket connect timeout | safe/replayable | +| `CONNECT_REFUSED` | connection 거부 | safe/replayable | +| `TLS_TRUST_FAILED` | trust/chain/revocation | false | +| `TLS_HOSTNAME_FAILED` | hostname mismatch | false | +| `TLS_HANDSHAKE_TIMEOUT` | handshake deadline | safe/replayable if not sent | +| `PROXY_FAILED` | proxy connect/auth/protocol | policy-specific | +| `REQUEST_WRITE_TIMEOUT` | request body write 제한 | mutation may be indeterminate | +| `RESPONSE_HEADER_TIMEOUT` | headers 대기 제한 | operation/transmission-specific | +| `RESPONSE_IDLE_TIMEOUT` | body progress 없음 | safe read may retry from start | +| `RESPONSE_TRUNCATED` | framing/body incomplete | safe/replayable; mutation usually indeterminate | +| `RESPONSE_TOO_LARGE` | wire/decoded cap 초과 | false | +| `UNSUPPORTED_MEDIA_TYPE` | content contract drift | false | +| `UNSUPPORTED_CONTENT_ENCODING` | encoding contract drift | false | +| `DECODE_FAILED` | malformed/schema mismatch | false | +| `UPSTREAM_STATUS` | operation-mapped status | operation-specific | +| `PROTOCOL_VIOLATION` | malformed framing/header/version | false by default | +| `AUTH_MATERIAL_UNAVAILABLE` | local secret/token failure | bounded refresh policy | +| `CANCELLED_BY_CALLER` | caller cancellation | false | +| `SHUTTING_DOWN` | lifecycle reject/cancel | false | +| `CALLBACK_FAILED` | application adapter stream consumer failure | false | +| `INTERNAL_CLIENT_DEFECT` | invariant/programming failure | false, alert | + +### 14.5 HTTP status default matrix + +Operation mapping이 우선하며 generic default는 보수적이다. + +| Status | Default | +| --- | --- | +| 200~299 | declared success set에 있을 때만 success | +| 300~399 | redirect disabled면 explicit upstream status failure | +| 400 | permanent request contract failure | +| 401 | credential profile이 허용하면 one refresh path, 일반 retry 아님 | +| 403 | permanent authz/config failure | +| 404 | operation이 absence로 선언한 경우만 domain absence | +| 408 | safe/replayable operation에서 bounded retry candidate | +| 409 | operation-specific conflict/in-flight/idempotency mapping | +| 410 | operation-specific terminal absence | +| 412 | precondition/domain concurrency outcome | +| 413 | request size/contract failure | +| 415/406 | media negotiation/config drift | +| 422 | application rejection 또는 idempotency fingerprint mismatch | +| 425 | replay-safe operation만 retry candidate | +| 429 | provider quota outcome; valid `Retry-After`와 budget 필요 | +| 500 | default no retry, operation opt-in 가능 | +| 501/505 | permanent capability/protocol mismatch | +| 502/503/504 | safe/replayable bounded retry candidate | + +Status code만으로 `retryable=true`를 application 외부 error envelope에 그대로 전달하지 않는다. +Retry가 안전한지는 현재 operation과 body/attempt evidence에 따라 달라진다. + +### 14.6 Error body + +Default는 discard-and-close다. Operation이 structured error mapping을 요구할 때만: + +- 별도 작은 `maxErrorBodyBytes`; +- accepted content type; +- bounded decoder; +- field allowlist; +- message/log 미노출; +- application-safe code로 mapping; +- malformed error body는 original status를 보존한 `ERROR_BODY_DECODE_FAILED` evidence. + +### 14.7 `Throwable` 금지 + +Infrastructure 경계가 모든 `Throwable`을 dependency failure로 바꾸지 않는다. + +- `VirtualMachineError`, `LinkageError`, `ThreadDeath` 등은 통과; +- `InterruptedException`은 interrupt flag 복구 후 cancellation/shutdown으로 분류; +- `CancellationException`은 별도; +- adapter programmer exception은 `INTERNAL_CLIENT_DEFECT`; +- engine/network exception만 taxonomy mapping; +- Error mapper 자체 failure가 original failure를 덮지 않음. + +## 15. Total deadline와 active cancellation + +### 15.1 Deadline 정의 + +Effective deadline: + +```text +min( + inherited application deadline, + operation total-deadline cap, + destination maximum call duration +) +``` + +Elapsed time은 monotonic clock으로 계산한다. + +```text +remaining = deadlineNanos - monotonicNowNanos +``` + +`Instant.now()`는 NTP/clock adjustment 영향을 받으므로 elapsed control에 사용하지 않는다. +`Retry-After` HTTP date 해석에만 wall clock을 사용하고 최종 sleep은 monotonic remaining으로 +제한한다. + +### 15.2 포함 범위 + +Total deadline에는 모두 포함된다. + +```text +logical admission wait ++ credential acquisition/refresh wait ++ request body encode/open/spool wait ++ local outbound quota wait ++ physical-attempt bulkhead wait ++ circuit permission acquisition ++ DNS ++ connection/HTTP2 stream lease ++ connect ++ TLS ++ request write ++ response headers ++ response body/callback ++ retry decision ++ Retry-After/backoff ++ every physical attempt +``` + +### 15.3 Phase cap + +Caller-visible absolute deadline을 `D`라 하고 positive finite `cleanupReserve`를 둔다. + +```text +executionCutoff = D - cleanupReserve +remainingExecution = executionCutoff - monotonicNow +remainingReturn = D - monotonicNow +``` + +Normal admission, backoff와 새 attempt는 `executionCutoff`까지만 허용한다. 각 실행 phase는: + +```text +effectivePhaseTimeout = min(configuredPhaseCap, remainingExecution) +``` + +을 사용한다. + +필수 cap: + +- admission acquire; +- credential acquire/refresh; +- body encode/open/spool; +- local quota acquire; +- physical bulkhead acquire; +- circuit permission acquire; +- pool acquire; +- DNS; +- connect; +- TLS handshake; +- request write/idle; +- response header; +- response body idle; +- total body/callback; + +Phase cap의 합을 total deadline으로 오해하지 않는다. 실제 phase는 순차/중첩되고 retry가 있으므로 +total은 별도 상한이다. + +Cleanup은 일반 phase가 아니라 reserve를 사용한다. + +```text +synchronousCleanupBudget = max(0, min(cleanupReserve, remainingReturn)) +``` + +`D <= now + cleanupReserve`이면 새 network side effect를 시작하지 않는다. Scheduler tolerance를 +cleanup budget으로 사용하지 않는다. + +### 15.4 Attempt 시작 조건 + +모든 physical attempt의 공통 조건: + +- remaining > `minimumAttemptBudget`; +- execution cutoff 전이며 cleanup reserve가 보존됨; +- 필요한 admission/connection acquisition 뒤에도 meaningful budget; +- logical call not cancelled; +- protected physical ceiling과 shared root-call total capacity가 남음; +- shutdown state가 이 call lease를 허용. + +최초 `attempt=0`은 retry가 아니다. + +- retry budget token을 요구하거나 소비하지 않음; +- operation/body가 replayable일 필요 없음; +- `SINGLE_USE_SOURCE`와 `NON_RETRYABLE_MUTATION`도 실행 가능; +- validation/encoding에서 network side effect 전 거절될 수 있음. + +`attempt>0`은 공통 조건에 더해 다음을 모두 만족한다. + +- previous `AttemptDisposition`이 정확히 후속 attempt를 허용; +- body가 동일 bytes/intent로 reopen 가능; +- applicable retry/pre-send-restart/auth-replay/operation-specific replay budget의 순수 availability 확인; +- backoff/`Retry-After` 뒤에도 meaningful budget; +- `physicalAttemptOrdinal < maxProtectedPhysicalAttemptsPerLogicalCall`이고 shared root-call total + token이 남음; +- identity-preserving replay라면 key/fingerprint/scope 동일. + +`NON_RETRYABLE_MUTATION`은 기본 `maxProtectedPhysicalAttemptsPerLogicalCall=1`이다. 별도 +reviewed pre-send restart policy가 +`RESTART_CONFIRMED_NOT_SENT`만 허용하고 body가 buffered/reopenable일 때 한 번의 attempt를 +추가로 허용할 수 있다. 이는 mutation replay/retry가 아니며 별도 restart budget/metric을 +사용한다. `MAYBE_SENT` 이상은 후속 mutation attempt를 절대 시작하지 않는다. + +Deadline 직전에 성공 가능성이 없는 attempt를 시작하지 않는다. + +### 15.5 Active cancellation 구현 + +Apache classic R2 provider는 blocking `RestClient` call을 adapter-owned virtual-thread task로 +실행할 수 있다. + +```text +caller + -> submit virtual-thread attempt task + -> wait only until execution cutoff + -> cutoff/cancel: + Future.cancel(true) + cancel request execution + close response/entity stream + hard-cancel connection when required + await cleanup only until caller-visible deadline D + if still active at D: + return to caller + quarantine connection/generation + hand off to bounded orphan reaper +``` + +구현 시 검증할 사항: + +- interrupt가 Apache request cancellation으로 실제 연결; +- `hardCancellationEnabled`의 connection 폐기 semantics; +- cancelled connection이 pool로 정상 반환되지 않음; +- stream callback 중 network read가 해제됨; +- executor shutdown이 in-flight task를 유실하지 않음; +- virtual-thread task 수가 logical admission으로 bounded; +- cancellation race에서 response가 정확히 한 번 close. + +Thread interrupt flag만 세우고 active cancellation이라 주장하지 않는다. + +### 15.6 Callback 한계 + +Streaming callback이 무한 CPU loop를 돌거나 interrupt를 무시하면 transport close만으로 callback +종료를 보장할 수 없다. + +계약: + +- callback은 blocking read/write interrupt와 cancellation token을 존중; +- adapter는 network stream을 close; +- callback에 checkpoint/cancellation view 제공 가능; +- non-cooperative callback hard kill은 보장하지 않음; +- test가 cooperative/non-cooperative behavior와 shutdown impact를 구분. + +### 15.7 Cancellation outcome + +Cancellation 원인을 구분한다. + +```text +PARENT_DEADLINE +OPERATION_DEADLINE +CALLER_CANCELLED +SHUTDOWN_DRAIN_EXPIRED +HEDGE_LOSER +``` + +OTel convention상 의도된 caller cancellation은 자동으로 dependency error로 기록하지 않는다. +그러나 mutation이 이미 전송됐으면 application outcome은 cancellation보다 +`INDETERMINATE`가 우선할 수 있다. + +### 15.8 Cleanup reserve와 orphan reaper + +Normal path는 execution cutoff에서 cancellation을 시작해 `D` 전에 response close, connection +discard, circuit/bulkhead permit 정리를 끝내는 것을 목표로 한다. + +`D`까지 cleanup이 끝나지 않으면 caller latency를 더 늘리지 않는다. + +- incomplete connection/stream은 reusable pool로 반환 금지; +- engine generation을 quarantine; +- physical-attempt permit는 실제 task termination까지 reaper가 보유해 capacity를 과다 판매하지 + 않음; +- logical call은 caller 반환 뒤 release하되 orphan count가 새 admission capacity에 반영됨; +- orphan registry와 reaper worker/queue/deadline은 finite; +- `orphanCleanupTimeout` 뒤에도 종료되지 않으면 generation을 `DEGRADED/NOT_READY`로 만들고 새 + call을 받지 않음; +- Java task를 강제 kill했다고 주장하지 않음; +- provider close/rollover/runbook escalation; +- synchronous logical latency SLO와 asynchronous cleanup SLO를 별도 metric으로 기록. + +따라서 보장은 다음처럼 구분한다. + +```text +caller return <= D + scheduler tolerance +normal cleanup target <= D +quarantined cleanup <= orphanCleanupTimeout +no quarantined resource reuse at any time +``` + +Parent cancellation이 이미 `D`를 지나 도착하면 synchronous cleanup budget은 0이며 즉시 +quarantine/reaper 경로를 사용한다. + +## 16. Replayability, idempotency와 unknown outcome + +### 16.1 세 개념을 분리한다 + +- HTTP method idempotency: 같은 intended effect를 반복해도 추가 effect가 없음. +- Body replayability: client가 동일 bytes/semantics를 다시 전송할 수 있음. +- Operation deduplication: upstream이 stable key/fingerprint로 같은 mutation을 식별하고 이전 + 결과를 replay함. + +하나가 다른 둘을 암시하지 않는다. + +### 16.2 Request body mode + +```text +NONE +BUFFERED_IMMUTABLE +REOPENABLE_SOURCE +SINGLE_USE_SOURCE +``` + +`BUFFERED_IMMUTABLE`: + +- size cap 안; +- canonical encoding 후 bytes/digest 고정; +- attempt마다 새 publisher/stream. + +`REOPENABLE_SOURCE`: + +- `open()`마다 처음부터 같은 content; +- stable length/checksum 또는 canonical fingerprint; +- concurrent open 허용 여부; +- 실패 시 close; +- source revision drift 검증. + +`SINGLE_USE_SOURCE`: + +- `maxProtectedPhysicalAttemptsPerLogicalCall=1`; +- redirect/auth replay 금지; +- response loss는 transmission evidence에 따라 indeterminate. + +### 16.3 Operation semantics + +```text +SAFE_READ +IDEMPOTENT_MUTATION +KEYED_MUTATION +NON_RETRYABLE_MUTATION +``` + +`GET`이라고 자동으로 `SAFE_READ`가 되지 않고 catalog review가 필요하다. `PUT`/`DELETE`도 +provider 문서와 precondition을 검증한다. + +### 16.4 Keyed mutation + +`KEYED_MUTATION` 요구: + +- upstream 문서화된 key contract; +- key format/entropy/length; +- uniqueness scope; +- retention/replay window; +- same key + different payload 처리; +- concurrent same-key 처리; +- success/error replay semantics; +- inspection/reconciliation endpoint; +- client-side stable operation attempt ID; +- canonical payload fingerprint; +- credential/tenant scope; +- real failure tests. + +2026-07 기준 IETF Idempotency-Key 문서는 만료된 Internet-Draft이며 표준 RFC로 취급하지 않는다. +Provider가 실제 지원하는 header와 semantics를 contract로 검증한다. + +### 16.5 Unknown mutation state + +다음 상황은 operation이 적용됐을 가능성이 있다. + +- request body 일부/전체 전송 후 connection reset; +- response header timeout; +- response body truncate; +- caller/shutdown cancellation after send; +- proxy/gateway가 upstream response를 잃음; +- success response decode 실패. + +결과: + +```text +Indeterminate( + operationAttemptId, + destinationId, + operationId, + requestFingerprint, + lastAttempt, + transmissionEvidence, + reconciliationHint +) +``` + +Raw URL, credential, payload는 receipt에 넣지 않는다. + +### 16.6 Exhaustive attempt disposition + +Kernel은 `mutation outcome unresolved` 같은 loose boolean을 사용하지 않는다. + +입력: + +```text +operationSemantics +transmissionEvidence +processingEvidence +cancellationOutcome +responseIntegrity +responseSemanticClass +bodyReplayability +reconciliationContract +credentialGeneration +authChallengeDecision +authReplayCount +remainingBudget +``` + +출력은 정확히 하나다. + +```text +RETURN_COMPLETED +RETURN_DECLARED_REJECTION +RETURN_CANCELLED(reason) +RESTART_CONFIRMED_NOT_SENT +RESTART_CONFIRMED_NOT_PROCESSED +RETRY_SAFE_READ +REPLAY_SAME_INTENT +REFRESH_CREDENTIAL_AND_REPLAY +FOLLOW_DECLARED_REDIRECT +RECONCILE_SAME_OPERATION +RETURN_INDETERMINATE +RETURN_PERMANENT_FAILURE +``` + +응답의 byte/framing 무결성과 operation 의미를 한 enum에 섞지 않는다. + +```text +ResponseIntegrity = + VALID_COMPLETE + | VALID_HEADERS_ONLY + | NONE_OR_INVALID + +ResponseSemanticClass = + COMPLETED + | DOMAIN_REJECTION + | RETRY_CONTROL + | STALE_CREDENTIAL_CHALLENGE + | DECLARED_REDIRECT + | RECONCILIATION_SIGNAL + | UNKNOWN +``` + +`VALID_HEADERS_ONLY`는 catalog가 status/header만으로 해당 control outcome을 authoritative하게 +판단할 수 있을 때만 사용한다. Success가 required body를 요구하는데 truncate/decode/schema +failure가 발생하면 `NONE_OR_INVALID`이며 status가 2xx라는 이유로 `COMPLETED`가 되지 않는다. +반대로 bounded error body decode가 실패해도 catalog가 429 status와 valid `Retry-After`만으로 +retry control을 선언했다면 원래 status를 보존할 수 있다. + +Terminal-result/cancellation CAS에서 cancellation이 먼저 이겼다면 response table보다 먼저 +분기한다. `SAFE_READ` 또는 exact `NOT_SENT` operation은 `RETURN_CANCELLED(reason)`이며 reason은 +caller/deadline/shutdown을 보존한다. Mutation이 `MAYBE_SENT` 이상 또는 `UNKNOWN`이면 cancellation을 +permanent failure로 축소하지 않고 `RETURN_INDETERMINATE`다. Authoritative response가 먼저 CAS를 +이겼다면 뒤늦은 cancellation은 아래 semantic result를 덮지 않는다. + +CAS 시점은 required integrity에 따라 다르다. Exact `VALID_HEADERS_ONLY` response outcome은 +status/header/framing 검증 직후 response winner를 시도하고, body-required outcome은 bounded +body/decode/semantic 검증이 끝난 직후 시도한다. Header-authoritative winner 뒤의 body +drain/discard는 cleanup일 뿐 outcome 확정을 늦추지 않는다. Response body task, provider failure와 +cancellation callback은 §20의 단일 `AttemptTerminalCoordinator`에 event를 제출하며 winner와 cleanup +owner를 각각 정확히 하나만 선출한다. + +Authoritative completed/rejection response가 없을 때는 `processingEvidence`를 semantic +`UNKNOWN`/transmission fallback보다 먼저 평가한다. + +| Provider processing evidence | Required contract | Disposition | +| --- | --- | --- | +| `CONFIRMED_NOT_PROCESSED` | exact RFC/provider evidence + operation H2 opt-in + replayable body + same identity + finite protocol-restart/protected/root budget | `RESTART_CONFIRMED_NOT_PROCESSED` | +| `MAYBE_PROCESSED` 또는 `UNKNOWN` | mutation | processing evidence로 restart 금지; semantic/fallback matrix 계속 평가 | + +따라서 request progress가 `SENT`인 mutation이라도 authoritative `CONFIRMED_NOT_PROCESSED`가 있으면 +아래 generic transmission fallback이 먼저 `RETURN_INDETERMINATE`로 종결하지 않는다. 반대로 valid +completed/domain-rejection response가 있으면 processing hint가 그 authoritative result를 덮지 않는다. + +그 다음 response semantic disposition precedence는 다음과 같다. + +| Semantic class | Required integrity/contract | Disposition | +| --- | --- | --- | +| `COMPLETED` | operation success contract의 required integrity 충족 | `RETURN_COMPLETED` | +| `DOMAIN_REJECTION` | operation이 status/header/body 중 요구한 rejection evidence 충족 | `RETURN_DECLARED_REJECTION` | +| `RETRY_CONTROL` | exact status profile + operation/body/transmission/budget gate | safe read는 `RETRY_SAFE_READ`; mutation은 authoritative same-intent/reconciliation 계약이 있을 때만 해당 state, 그 외 indeterminate/permanent | +| `STALE_CREDENTIAL_CHALLENGE` | exact configured challenge + §17.5 gate | `REFRESH_CREDENTIAL_AND_REPLAY`, 아니면 terminal auth rejection | +| `DECLARED_REDIRECT` | future redirect card + §24.3 hop/body/origin gate | `FOLLOW_DECLARED_REDIRECT`; current cards에서는 terminal reject | +| `RECONCILIATION_SIGNAL` | provider-specific inspect/reconcile contract | `RECONCILE_SAME_OPERATION` 또는 authoritative terminal result | +| `UNKNOWN` 또는 required integrity 미충족 | 아래 fallback matrix | transmission evidence에 따른 disposition | + +임의의 401, 403, malformed challenge 또는 provider가 선언하지 않은 error body를 refresh 신호로 +추측하지 않는다. 408/425/429/5xx가 framing-complete response라는 이유만으로 terminal rejection이 +되는 것도 아니며, operation catalog의 exact `RETRY_CONTROL` mapping을 통과해야 한다. + +`UNKNOWN`/required-integrity-failure fallback 결정표: + +| Semantics | `NOT_SENT` | `MAYBE_SENT` / `SENT` / `RESPONSE_STARTED` / `RESPONSE_COMPLETE` / `UNKNOWN` | +| --- | --- | --- | +| `SAFE_READ` | failure/status profile이 허용하면 `RESTART_CONFIRMED_NOT_SENT` | failure/status profile + replayable body + budget이 모두 허용하면 `RETRY_SAFE_READ`, 아니면 permanent failure | +| `IDEMPOTENT_MUTATION` | policy가 허용하면 `RESTART_CONFIRMED_NOT_SENT` | catalog가 same-intent replay 결과도 authoritative라고 증명한 경우만 `REPLAY_SAME_INTENT`; 그 외 `RETURN_INDETERMINATE` | +| `KEYED_MUTATION` | policy가 허용하면 `RESTART_CONFIRMED_NOT_SENT` | generic retry 금지, contract가 있으면 `RECONCILE_SAME_OPERATION`, 없으면 `RETURN_INDETERMINATE` | +| `NON_RETRYABLE_MUTATION` | protected physical ceiling 1이 기본. buffered/reopenable body + explicit ceiling 2 restart policy가 exact `NOT_SENT`에만 opt-in한 경우 `RESTART_CONFIRMED_NOT_SENT`, 아니면 permanent before-send failure | 항상 `RETURN_INDETERMINATE` | + +`RESPONSE_COMPLETE`라도 terminal semantic result가 invalid하면 mutation effect를 부정하지 못한다. +`MAYBE_SENT`와 `UNKNOWN`을 `NOT_SENT`로 낮추지 않는다. + +`RESTART_CONFIRMED_NOT_SENT`는 upstream mutation transmission이 없었다는 engine evidence 뒤의 +pre-send restart다. Retry/replay metric과 budget에 섞지 않지만 새 physical attempt, deadline, +quota/bulkhead/CB/span에는 그대로 집계한다. + +HTTP/2 provider가 §30.2의 exact `CONFIRMED_NOT_PROCESSED`를 반환하면 +`RESTART_CONFIRMED_NOT_PROCESSED` 후보가 된다. Operation catalog의 explicit H2 restart opt-in, +replayable body, same identity, finite protocol-restart budget, shared physical/root ceiling과 deadline을 +모두 요구한다. 이는 engine autonomous resend가 아니라 provider가 현재 attempt를 종료하고 kernel이 +새 physical request를 승인하는 state다. `MAYBE_PROCESSED|UNKNOWN`은 이 disposition을 만들지 않는다. +Non-retryable mutation도 provider/RFC evidence가 authoritative `NOT_PROCESSED`이고 operation이 +opt-in한 경우에만 허용하며 one-shot body에는 허용하지 않는다. + +`FOLLOW_DECLARED_REDIRECT`도 engine auto-follow가 아니다. Prior 3xx response/resource를 닫고 future +redirect card의 hop token과 attempt authorization을 얻은 뒤, target origin/DNS/credential/body를 +다시 검증해 같은 kernel loop로 들어간다. Current canonical card set에서는 항상 terminal reject다. + +`REFRESH_CREDENTIAL_AND_REPLAY`는 다음 조건을 모두 만족할 때만 선택한다. + +```text +configured stale/expired-token challenge exactly matched +AND challenged credential generation is older than the currently usable generation + OR a bounded single-flight refresh for that generation is required +AND authReplayCount == 0 +AND operation semantics is SAFE_READ + OR operation contract states that this exact 401 authoritatively means NOT_APPLIED +AND operation semantics is not NON_RETRYABLE_MUTATION +AND body is ABSENT, BUFFERED_REPLAYABLE or REOPENABLE_SOURCE +AND body mode is not SINGLE_USE_SOURCE +AND same operation-attempt identity and credential scope can be preserved +AND auth replay/amplification budget is available +AND caller is not cancelled and execution cutoff leaves the minimum attempt budget +``` + +401을 받았다는 사실만으로 mutation 미적용을 추론하지 않는다. `IDEMPOTENT_MUTATION` 또는 +`KEYED_MUTATION`은 provider contract가 그 challenge를 authoritative `NOT_APPLIED`로 선언하고 +같은 operation ID/key/fingerprint를 유지할 때만 auth replay할 수 있다. +`NON_RETRYABLE_MUTATION`과 `SINGLE_USE_SOURCE`는 auth replay를 항상 금지한다. + +Auth replay는 ordinary retry나 pre-send restart가 아니지만 새 physical attempt다. 따라서 +attempt/resend count, total deadline, local quota, physical bulkhead, circuit permission과 attempt +span을 다시 거치며 total amplification dashboard에 포함한다. Ordinary retry token을 소비하지 +않고 별도의 one-token auth-replay budget을 원자적으로 소비한다. + +`REPLAY_SAME_INTENT`와 `RECONCILE_SAME_OPERATION`은 다음 identity를 그대로 유지한다. + +```text +operationAttemptId +idempotencyKey +requestFingerprint +operationId +tenant/credential scope +``` + +새 key/fingerprint로 mutation을 재시작하지 않는다. Authoritative reconciliation이 +`CONFIRMED_NOT_APPLIED`를 반환한 뒤에만 같은 logical identity로 새 physical mutation attempt를 +시작할 수 있다. + +### 16.7 Reconciliation + +Operation이 다음 중 하나를 제공해야 automatic mutation retry를 허용할 수 있다. + +- same idempotency key replay가 authoritative result를 반환; +- operation ID로 status inspection; +- provider resource ID + conditional lookup; +- externally visible durable receipt; +- verified `NOT_APPLIED` evidence. + +Reconciliation outcome: + +```text +CONFIRMED_APPLIED(result/reference) +CONFIRMED_NOT_APPLIED +STILL_IN_PROGRESS +UNKNOWN +KEY_EXPIRED +PAYLOAD_MISMATCH +``` + +`CONFIRMED_NOT_APPLIED`일 때만 새 attempt 여부를 policy가 결정한다. `UNKNOWN`을 success/failure로 +추측하지 않는다. + +Mapping: + +| Reconciliation result | Kernel disposition | +| --- | --- | +| `CONFIRMED_APPLIED` | `RETURN_COMPLETED` with authoritative result/reference | +| `CONFIRMED_NOT_APPLIED` | budget/replay policy가 허용하면 same-identity new physical attempt | +| `STILL_IN_PROGRESS` | bounded later reconciliation, caller에는 indeterminate receipt | +| `UNKNOWN` | `RETURN_INDETERMINATE` | +| `KEY_EXPIRED` | `RETURN_INDETERMINATE`, blind new key 금지 | +| `PAYLOAD_MISMATCH` | permanent local/provider contract failure, security alert | + +In-call reconciliation은 다음 exact state를 따른다. + +1. prior mutation response/handle과 circuit permission을 완료하고 bulkhead를 반납한다. Committed + local quota handle은 닫되 소비한 token을 환불하지 않는다. +2. catalog에 등록된 `SAFE_READ` reconciliation operation만 선택한다. Raw URL/임의 operation은 + 허용하지 않고 별도 resilience group과 pool/admission 상한을 사용한다. +3. 같은 parent execution cutoff, cancellation token과 root-call HTTP-request-attempt budget을 + 전달한다. Poll 횟수/간격과 `maxReconciliationRequestsPerLogicalCall`은 finite하다. +4. 각 poll wait 직후 common gate와 registered child operation의 pure eligibility를 재검사하고 + `tryAuthorizeNestedHttpRequest(RECONCILIATION, childProfileFingerprint)`를 정확히 한 번 호출한다. + 반환된 `NestedHttpAuthorizationLease`는 child physical attempt까지 carrying하며 같은 poll에서 + 다시 acquire하지 않는다. Child-level safe-read retry를 허용하면 새 wire request마다 새 poll + ordinal/lease가 필요하고 child retry budget availability도 같은 atomic authorization에 포함한다. +5. Child request의 body/pool/quota/bulkhead/CB acquisition 뒤 wire start 직전에 shared root HTTP token을 + reserve한다. Cancellation/start race에서 start가 이길 때만 lease/root token을 bind/commit한다. + Pre-bind exit는 lease를 `ABORTED`로 exactly once 닫고 uncommitted root/quota를 반납한다. 현재 + logical call에서 detached background task를 만들지 않는다. +6. `CONFIRMED_NOT_APPLIED`만 same operation identity의 `REPLAY_SAME_INTENT`로 돌아가며 별도 replay + budget과 protected/root physical token을 소비한다. 나머지는 위 mapping대로 terminal/receipt다. + +Deadline 안에 확인되지 않으면 kernel은 `Indeterminate` receipt를 반환한다. 이후 scheduler/use +case가 수행하는 reconciliation은 새 root-call budget과 별도 scheduler quota를 갖고 원래 +operation-attempt identity를 이어받는다. + +### 16.8 Application orchestration + +HTTP kernel이 business compensation을 수행하지 않는다. + +```text +use case + -> stable operation attempt ID 저장/보유 + -> outbound port mutation + -> Indeterminate + -> reconciliation use case/scheduler + -> confirmed state + -> next business transition/compensation +``` + +DB transaction 안에서 remote HTTP mutation을 호출하고 rollback이 remote effect도 취소한다고 +가정하지 않는다. + +## 17. Retry policy와 retry budget + +### 17.1 Pure eligibility와 exactly-once attempt authorization + +후속 attempt 판정은 state를 바꾸지 않는 pure eligibility와 token을 한 번만 소비하는 authorization을 +분리한다. 최초 request는 ordinary reason token을 요구하지 않지만 shared protected/root physical +token은 engine handoff 직전에 소비한다. + +Pure eligibility: + +```text +operation policy allows this exact AttemptDisposition +AND request body replayable when the disposition needs replay +AND failure/status/processing-evidence profile allows this disposition +AND call not cancelled/shutting down +AND absolute deadline has minimum attempt budget +AND reason-specific counter, protected physical ceiling and root-call total capacity are available +AND exact reason budget currently has a token +``` + +Pure 함수는 budget/CB/quota/ordinal을 획득하거나 증가시키지 않는다. Eligibility가 true인 뒤 +state machine이 backoff 등 disposition별 wait를 마치고 gate를 재검사한 다음 단 한 번 +`tryAuthorizeNextAttempt`를 호출한다. + +```text +AttemptAuthorizationLease( + disposition, + reasonBudgetId, + logicalCallId, + operationIdentityDigest, + state = AUTHORIZED | BOUND_TO_PHYSICAL_ATTEMPT | ABORTED +) +``` + +`tryAuthorizeNextAttempt`는 exact reason token과 counter를 원자적으로 한 번 소비한다. Lease가 다음 +loop로 carrying되므로 auth/retry/replay 분기가 다시 token을 소비하지 않는다. Initial request에는 +`INITIAL` authorization marker만 있고 reason token은 없다. Authorized 뒤 deadline, refresh 또는 +final protected/root reservation이 실패하면 lease는 `ABORTED`로 exactly once 닫고 default로 token을 +환불하지 않아 churn이 amplification budget을 되살리지 못하게 한다. Circuit permission, local +quota와 protected/root physical token은 이 lease와 별도로 §20의 실제 attempt 경계에서 획득한다. + +Disposition/semantics gate: + +| Semantics | Ordinary subsequent attempt | +| --- | --- | +| `SAFE_READ` | `RESTART_CONFIRMED_NOT_SENT` 또는 `RETRY_SAFE_READ` | +| `IDEMPOTENT_MUTATION` | `RESTART_CONFIRMED_NOT_SENT`; same-intent/H2-not-processed는 별도 state | +| `KEYED_MUTATION` | `RESTART_CONFIRMED_NOT_SENT`; reconciliation/same-intent/H2-not-processed는 별도 state | +| `NON_RETRYABLE_MUTATION` | default 금지; explicit protected ceiling 2 + reopenable body + restart budget의 `RESTART_CONFIRMED_NOT_SENT`, 또는 H2 exact opt-in의 `RESTART_CONFIRMED_NOT_PROCESSED`만 | + +`REPLAY_SAME_INTENT`, `RESTART_CONFIRMED_NOT_PROCESSED`, `REFRESH_CREDENTIAL_AND_REPLAY`와 +`FOLLOW_DECLARED_REDIRECT`는 ordinary retry predicate를 재사용하지 않고 동일 pure-eligibility + +reason-specific `AttemptAuthorizationLease` protocol을 사용한다. `RECONCILE_SAME_OPERATION`은 prior +protected attempt를 반복하지 않고 §16.7의 registered child state로 들어가며, 각 실제 reconciliation +HTTP request는 별도 `NestedHttpAuthorizationLease`에 bind된다. 모든 state는 동일 +key/fingerprint/scope와 shared root ceiling을 유지한다. + +### 17.2 Backoff + +Default: + +- exponential backoff; +- full jitter 또는 decorrelated jitter를 명시; +- zero busy-loop 금지; +- upper cap; +- absolute deadline cap; +- retry budget cap. + +예시 full jitter: + +```text +cap_n = min(maxBackoff, initialBackoff * 2^n) +sleep_n = random(0, cap_n) +sleep_n = min(sleep_n, remaining - minimumAttemptBudget) +``` + +Random source는 test에서 deterministic injection 가능해야 한다. + +### 17.3 `Retry-After` + +408/429/503 등 operation이 허용한 status에서만 해석한다. + +- delta-seconds와 HTTP-date만 허용하고 multiple/mixed 값은 invalid; +- malformed, negative 또는 overflow delta는 server hint를 버리고 bounded client jitter로 fallback; +- 문법상 valid하지만 과거인 HTTP-date는 `serverMinimumDelay=0`으로 두되 client jitter는 유지; +- maximum cap; +- wall-clock skew tolerance; +- remaining deadline보다 길면 현재 logical call에서는 retry하지 않음; +- provider-specific rate-reset header는 별도 parser; +- invalid header는 bounded metric, raw value log 금지. + +Server hint와 client jitter 조합은: + +```text +delay = max(serverMinimumDelay, clientBackoffWithJitter) +``` + +를 기본으로 하되 operation contract가 다르면 명시한다. + +### 17.4 Retry budget + +대규모 장애 때 모든 최초 호출이 N번 retry하면 outage를 증폭한다. Destination/resilience group별 +retry budget을 둔다. + +가능한 정책: + +- token bucket; +- 성공 call 비율 기반 token replenish; +- rolling retry/original ratio cap; +- minimum reserved original-call capacity. + +Metric: + +```text +http.client.retry.attempts +http.client.retry.exhausted +http.client.retry.budget.rejected +http.client.presend.restarts +http.client.presend.restart.exhausted +``` + +Retry budget은 provider quota의 정확한 cluster-wide enforcement를 주장하지 않는다. Pod별 local +보호 장치다. Pre-send restart budget/metric은 retry budget과 분리하되 둘 다 total amplification +dashboard에 포함한다. + +### 17.5 Credential refresh는 retry와 분리 + +401 처리 순서는 다음과 같다. + +1. operation/auth profile에 등록된 invalid/expired-token status, challenge와 bounded error + contract가 정확히 일치하는지 확인한다. +2. 401 response body/connection을 drain-or-discard policy로 닫고 기존 attempt의 circuit + permission을 완료한다. Bulkhead는 반납하고 committed quota handle은 exactly once 닫되 token은 + 환불하지 않는다. Protected destination의 해당 + 401은 breaker-ignore이며 token endpoint failure와 섞지 않는다. +3. §16.6의 `REFRESH_CREDENTIAL_AND_REPLAY` pure eligibility만 평가한다. 여기서는 token을 + 소비하지 않는다. 조건이 하나라도 거짓이면 401을 terminal auth failure로 반환한다. +4. §17.1의 `tryAuthorizeNextAttempt`가 one-token auth-replay + `AttemptAuthorizationLease`를 정확히 한 번 만든 뒤 common deadline/cancellation/shutdown gate를 + 다시 확인하고 bounded single-flight refresh에 참여한다. +5. 각 waiter는 자신의 absolute execution cutoff와 cancellation token을 사용한다. Waiter가 + timeout/cancel되면 shared refresh에서 detach하고 즉시 반환하며, shared refresh는 active + waiter/owner가 0일 때만 취소한다. Waiter bound를 넘으면 local admission failure다. +6. 실제 token fetch는 별도 named destination, pool, quota/bulkhead, circuit breaker와 token + operation policy를 사용한다. Protected destination의 attempt permit/connection을 보유한 채 + refresh하지 않으며 자기 자신을 재귀 호출하지 않는다. +7. refresh wait 직후 common gate와 minimum attempt budget을 다시 확인한다. 성공한 새 + generation의 scope/audience/tenant binding을 검증하고 원래 operation-attempt identity, + idempotency key와 payload fingerprint를 유지한다. +8. 다음 요청은 ordinary physical-attempt loop의 body-open, quota, bulkhead, circuit permission, + attempt span과 engine-start gate를 모두 새로 거친다. 정확히 한 번만 auth replay하고 두 번째 + 401은 refresh 없이 terminal이다. + +Single-flight key는 `TokenCacheKey + challengedCredentialGeneration`이다. 여러 root call이 같은 +flight에 합류할 때 실제 token network call의 owner와 deadline을 고정한다. + +```text +RefreshFlight( + key, + immutableFlightDeadline, + creatorRootCallId, + creatorRefreshBudgetLease, + activeWaiters, + state +) +``` + +- creator election winner만 자신의 remaining nested-credential/root HTTP capacity에서 finite + token-operation attempt slice를 고정해 `creatorRefreshBudgetLease`로 flight에 이전한다. 이 slice는 + 상한 소유권이며 아직 wire token을 소비하지 않는다; +- `immutableFlightDeadline = min(creatorExecutionCutoff, now + tokenCallCap)`이며 joiner가 연장하지 + 못한다; +- 실제 child attempt마다 §9.4의 `NestedHttpAuthorizationLease(OAUTH_TOKEN)`를 하나 만들고 token + write 직전에 creator root의 shared HTTP token과 bind/commit한다. 사용하지 않은 slice capacity는 + flight terminal close 때 해제하지만 이미 authorize/실행한 attempt token은 환불하지 않는다; +- joiner는 존재하지 않는 shared network attempt를 자기 root count에 중복 기록하지 않는다. 대신 + 자신의 auth `AttemptAuthorizationLease`, waiter admission과 후속 protected/root replay capacity를 + 보유해야 한다; +- creator가 cancel/detach돼도 다른 waiter가 있으면 이미 이전된 lease/deadline으로 flight가 + 계속된다. active waiter가 0이면 cancel하고, deadline owner를 다른 root로 바꾸거나 늘리지 않는다; +- 짧은 creator deadline으로 flight가 실패하면 joiner는 terminal refresh failure를 받고 같은 auth + lease로 새 flight를 반복 생성하지 않는다. + +Refresh call의 자체 retry는 token operation이 선언한 semantics와 budget만 사용하며 protected +operation의 retry count에 합산하지 않는다. 반면 protected request의 auth replay는 +`http.request.resend_count`, logical/physical call count와 total amplification에 포함한다. + +최소 metric: + +```text +http.client.auth.refresh.calls +http.client.auth.refresh.waiters +http.client.auth.refresh.failures +http.client.auth.replays +http.client.auth.replay.rejected +``` + +Raw token, client ID, tenant/user, scope의 unbounded 값과 credential generation은 metric tag에 +넣지 않는다. Invalid request를 401마다 무한 재전송하지 않는다. + +### 17.6 Engine hidden retry 금지 + +Apache: + +- automatic retry strategy disabled; +- automatic redirect disabled; +- automatic auth challenge replay가 credential policy를 우회하지 않음; +- stale connection retry semantics를 검증; +- protocol upgrade/resend 관측. + +JDK/reactive provider를 포함해 사후 문서화/count만으로는 충분하지 않다. Protocol event가 새 +request를 요구하면 provider는 현재 attempt를 종료하고 transmission/processing evidence를 kernel에 +반환한다. Kernel이 `RESTART_CONFIRMED_NOT_PROCESSED` 등 exact disposition을 만들고 새 +`AttemptAuthorizationLease`, deadline/body/protected-root budget/quota/bulkhead/CB/span gate를 모두 +통과한 뒤에만 다음 request를 시작한다. + +Engine autonomous retry/auth/redirect/protocol resend가 이 pre-resend authorization 경계로 제어되지 +않으면 반드시 disable한다. Disable할 수 없거나 callback이 실제 wire start보다 늦으면 해당 +provider/profile은 release-eligible이 아니다. 보이지 않는 resend를 사후 span/count로 보정하지 +않는다. + +### 17.7 Hedging + +R2 default는 disabled. + +허용 조건: + +- safe read; +- duplicate load 허용; +- distinct endpoint/connection; +- shared total deadline; +- retry/hedge combined amplification budget; +- loser active cancellation; +- provider quota 반영; +- attempt별 span; +- no mutation. + +Hedging은 retry와 같은 config boolean이 아니라 R3 candidate card다. + +## 18. Circuit breaker semantics + +### 18.1 Default unit + +Default circuit breaker는 `resilienceGroupId`별 physical attempt를 집계한다. + +```text +retry loop + -> physical bulkhead + -> circuit permission lease + -> one physical attempt + -> exactly-once record/ignore/release +``` + +`destinationId` 하나에 모든 operation을 무조건 합치면 cheap health read와 expensive mutation이 +서로 circuit을 오염시킨다. 반대로 operation마다 breaker를 만들면 state와 metric cardinality가 +폭증한다. Committed bounded resilience group을 사용한다. + +Circuit permission은 boolean이 아니라 `CircuitPermissionLease`로 관리한다. + +```text +ACQUIRED + -> local preflight failure: RELEASED exactly once + -> immediately before provider ownership: STARTING (CAS) +STARTING + -> provider accepted handle: STARTED + -> synchronous start throw: tracker evidence로 RECORDED_SUCCESS/RECORDED_ERROR/RELEASED exactly once +STARTED + -> recordable success: RECORDED_SUCCESS exactly once + -> recordable failure: RECORDED_ERROR exactly once + -> ignored outcome: RELEASED exactly once +``` + +`engine.start()` 뒤에 started flag를 쓰는 순서를 금지한다. 그러면 synchronous send/failure가 flag +보다 먼저 발생해 permission을 잘못 release할 수 있다. `STARTING` 전이, transmission tracker와 +provider ownership handoff를 하나의 protocol로 묶고 어느 경로든 atomic terminal state 하나만 +허용한다. + +Bulkhead/body/span/local preflight failure, cancellation, deadline, synchronous start failure, +half-open race도 finally 경로에서 permission을 회수한다. Resilience4j API의 ignore predicate가 +permission을 어떻게 반환하는지 추측하지 않고 adapter wrapper test로 고정한다. + +### 18.2 Record matrix + +Default record: + +- connect/DNS transient; +- response header/read timeout; +- selected 5xx; +- response truncated; +- provider overload; +- slow call threshold 초과. + +Default ignore: + +- application cancellation; +- shutdown; +- local admission/bulkhead/rate reject; +- caller/codec/programming defect; +- response size/media/schema contract violation; +- expected domain 404/409/412; +- 4xx config/auth error; +- SSRF/TLS policy rejection; +- circuit-open rejection 자체. + +Operation-specific status mapping 이후 breaker outcome을 결정한다. Raw exception class만으로 +breaker를 기록하지 않는다. + +### 18.3 Slow-call policy + +Failure rate와 별도로 slow-call rate를 구성할 수 있다. + +- slow threshold < operation total deadline; +- body mode별 threshold 분리; +- streaming 전체 duration을 일반 JSON call과 같은 group에 넣지 않음; +- callback CPU time 포함 여부 명시; +- slow success도 capacity risk로 기록 가능. + +### 18.4 Half-open + +- bounded concurrent probes; +- retry disabled, ordinaryRetryCount=0, protected physical ceiling=1; +- provider quota 존중; +- representative safe operation만 probe; +- mutation을 half-open probe로 사용 금지; +- shutdown 중 probe 금지. + +Automatic transition scheduler를 켜면 no-binding/disabled 상태에서 thread가 생기지 않아야 한다. + +### 18.5 Logical-call breaker + +일부 조직은 사용자에게 보인 논리 호출 성공률을 breaker에 반영할 수 있다. 이 경우 별도 정책 ID: + +```text +physical-attempt-breaker +logical-call-breaker +``` + +를 사용하고 meter/span/test도 분리한다. Wrapper order의 우연한 side effect로 선택하지 않는다. + +### 18.6 State와 deployment + +Resilience4j breaker state는 process-local이다. + +- pod마다 state가 다를 수 있음; +- rolling restart에서 reset; +- cluster-wide exact breaker 아님; +- 이 특성이 R2 availability protection에는 허용됨; +- shared distributed breaker를 위해 Redis/DB adapter에 직접 의존하지 않음. + +## 19. Admission, bulkhead와 outbound quota + +### 19.1 두 단계 bound + +1. Logical admission: + - in-flight logical calls + backoff waiters 전체를 제한; + - bounded queue 또는 immediate reject; + - parent deadline 포함. +2. Physical attempt bulkhead: + - 실제 pool/network attempt만 제한; + - backoff 중 permit 미보유; + - per resilience group/destination. + +Connection pool만으로 logical retry storm을 막을 수 없고, logical semaphore 하나를 backoff 동안 +보유하면 healthy work가 starvation될 수 있다. 두 목적을 분리한다. + +### 19.2 Queue + +Default: + +- unbounded queue 금지; +- queue capacity 명시; +- FIFO/fairness policy 명시; +- acquire timeout은 remaining deadline 이하; +- queue full과 deadline expiry 구분; +- request body를 queue 전에 대용량 materialize하지 않음; +- cancelled waiter 즉시 제거. + +### 19.3 Virtual threads + +Virtual thread는 blocking 비용을 낮추지만 downstream capacity를 늘리지 않는다. + +필요: + +- max logical concurrent; +- max protected physical attempts와 root-call total attempts; +- pool capacity; +- response-body memory budget; +- streaming connection budget; +- credential refresh bound. + +예시 capacity constraint: + +```text +maxBufferedInFlight * maxBufferedResponseBytes ++ maxBufferedRequestBytes ++ decoder overhead +<= allocated HTTP heap budget +``` + +정확한 수치는 배포 workload로 산정하고 템플릿이 임의 숫자를 성능 보장으로 제시하지 않는다. + +### 19.4 Local outbound quota + +Provider API quota를 보호하기 위한 local token bucket/leaky bucket을 optional로 제공할 수 있다. + +- destination/operation group key만 사용; +- user/tenant high-cardinality limiter 아님; +- request cost weight 지원; +- monotonic refill; +- bounded wait 또는 reject; +- `Retry-After` local result; +- pod 수 증가 시 aggregate quota가 증가함을 명시. + +정확한 조직 전체 quota가 필요하면 API gateway/provider-side quota 또는 별도 distributed +coordination capability를 사용한다. HTTP adapter가 Redis에 직접 의존하지 않는다. + +### 19.5 Bulkhead와 pool 관계 + +권장: + +```text +attemptBulkhead.maxConcurrent <= usablePoolCapacity +``` + +HTTP/1.1에서는 active request당 대체로 connection 하나가 필요하다. HTTP/2에서는 connection +수와 concurrent stream 수를 별도 계산한다. Pool pending queue와 adapter queue를 둘 다 크게 +두어 이중 queue를 만들지 않는다. + +## 20. 정확한 실행 순서와 state machine + +### 20.1 Default logical call + +```text +1. resolve registered operation/destination and dependency DAG +2. validate application request, auth profile and stable operation-attempt identity +3. compute absolute effective deadline and finite root-call amplification budget +4. acquire logical admission +5. encode/freeze buffered body or prepare reopenable source +6. enter explicit attempt state machine; do not freeze a credential generation for the whole call +7. release logical admission +8. map kernel result to application result +``` + +Logical preflight validates the auth profile/reference only. Current credential generation is selected for +each attempt after backoff and before protected attempt resources are held. Network credential refresh uses the +separate nested dependency path. Final credential injection/signing happens only after final URI/header/body +bytes are fixed and immediately before engine ownership transfer. + +### 20.2 Physical attempt loop + +```text +nextDisposition = INITIAL_ATTEMPT +nextAuthorization = INITIAL +physicalAttemptOrdinal = 0 +while a disposition can start another protected request: + check common cancellation/shutdown/deadline gate + require protected physical ceiling and root-call HTTP budget have capacity + + if nextDisposition is not INITIAL_ATTEMPT: + evaluate pure eligibility against semantics/body/identity/evidence/reason capacity + if ineligible -> return mapped terminal result without consuming a reason token + if nextDisposition is RESTART_CONFIRMED_NOT_SENT or RETRY_SAFE_READ: + wait bounded backoff or valid Retry-After without holding attempt permit/connection + recheck common gate and pure eligibility + nextAuthorization = tryAuthorizeNextAttempt(nextDisposition) exactly once + if authorization failed -> return reason-budget rejection + if nextDisposition is REFRESH_CREDENTIAL_AND_REPLAY: + join/create bounded RefreshFlight using the authorization lease + validate refreshed generation scope and detach waiter exactly once + if nextDisposition is FOLLOW_DECLARED_REDIRECT: + resolve hop target and revalidate method/body/origin/DNS/credential policy + if nextDisposition is RESTART_CONFIRMED_NOT_PROCESSED: + revalidate exact H2 processing evidence and protocol-restart opt-in + recheck common gate; carry the same nextAuthorization lease forward + + select current credential generation; perform any network refresh only through nested dependency + recheck common gate + open initial body handle; on ordinal > 0 open a fresh identical buffered/reopenable body + recheck common gate + acquire uncommitted local-quota reservation with absolute execution cutoff + recheck common gate + acquire physical-attempt bulkhead with absolute execution cutoff + recheck common gate + acquire circuit-breaker permission lease without blocking past cutoff + recheck common gate + finalize URI/header/body metadata; validate generation expiry/scope and inject/sign credential + recheck common gate immediately before engine ownership transfer + + on any refresh/hop/protocol revalidation, body-open, quota, bulkhead, CB, credential-signing, + local preflight or common-gate exit after authorization but before physical reservation: + abort nextAuthorization exactly once if its state is AUTHORIZED + release every acquired body/CB/bulkhead/uncommitted-quota resource in reverse order + engine start count = 0; return the exact mapped local/cancellation/deadline result + + physicalReservation = atomically tryReserve one protected slot + one root HTTP-request slot + if reservation failed: + abort nextAuthorization if AUTHORIZED; release CB/bulkhead/uncommitted quota/body in reverse order + engine start count = 0; return amplification-budget rejection + + prepare CLIENT span context, ordinal and one AttemptTerminalCoordinator before provider callback is possible + coordinator winner = OPEN -> RESPONSE_WON | FAILURE_WON | CANCELLATION_WON + coordinator cleanup = UNCLAIMED -> RUNNING -> DONE + atomically race pre-start cancellation gate against CB ACQUIRED -> STARTING handoff + if cancellation won before STARTING: + release uncommitted physicalReservation and quota reservation + abort nextAuthorization if AUTHORIZED; release CB/bulkhead/body; engine start count = 0 + return RETURN_CANCELLED(reason) because transmission is exact NOT_SENT + if STARTING won: + atomically transfer cancellation token/handle + transmission tracker to provider + bind nextAuthorization to the ordinal when it is an authorization lease; record INITIAL marker otherwise + commit physical/root reservation and increment physicalAttemptOrdinal + commit local-quota token; it is consumed and never refunded + start one CLIENT span and invoke cancel-aware engine start with remaining phase budgets + if cancellation was already signaled, provider observes pre-cancelled token before first write + provider marks STARTED or reports synchronous start failure through the same tracker + + all provider response/body/failure callbacks and cancellation submit events to the same coordinator + on response headers while winner is OPEN: + classify status/header/framing and choose bounded success/error decoder before body consumer + if exact VALID_HEADERS_ONLY outcome is authoritative: + tryWin RESPONSE_WON immediately with immutable header semantic evidence + run bounded drain/discard only as coordinator-owned cleanup; it cannot change the winner + else: + register exactly one coordinator-owned bounded body consumer + if required body/decode/semantic validation completes: + finalize ResponseIntegrity and ResponseSemanticClass; tryWin RESPONSE_WON + if body/framing/decode fails before an authoritative outcome: + preserve NONE_OR_INVALID + failure evidence; tryWin FAILURE_WON + on provider transport failure: + preserve absent/partial response, processing and transmission evidence; tryWin FAILURE_WON + on caller/deadline/shutdown cancellation: + preserve exact cancellation outcome and current evidence; tryWin CANCELLATION_WON + + after one winner is visible: + coordinator claims exactly one cleanup finalizer + losing callbacks/body tasks relinquish body and cleanup ownership and observe the cleanup cancel token + preserve winner semantic class, processing evidence and monotonic transmission evidence + close/cancel exact resources + complete STARTING/STARTED circuit permission once from tracker + mapped outcome + release bulkhead; release only uncommitted quota or close committed handle without refund + finish span before any between-attempt wait + mark coordinator cleanup DONE; resolve exhaustive AttemptDisposition from the immutable winner evidence + if disposition is RETURN_COMPLETED/RETURN_DECLARED_REJECTION -> return mapped outcome + if disposition is RETURN_CANCELLED -> return cancellation reason + if disposition is RETURN_INDETERMINATE -> return receipt + if disposition is RETURN_PERMANENT_FAILURE -> return mapped permanent failure + if disposition is RECONCILE_SAME_OPERATION: + enter §16.7 bounded reconciliation child state; acquire no protected-attempt lease here + each child wire request obtains its own NestedHttpAuthorizationLease and root token + if child state returns terminal/receipt -> return it + if child state returns REPLAY_SAME_INTENT -> set nextDisposition; continue + if disposition is REPLAY_SAME_INTENT + or REFRESH_CREDENTIAL_AND_REPLAY + or FOLLOW_DECLARED_REDIRECT + or RESTART_CONFIRMED_NOT_PROCESSED: + require previous response/body, breaker permission, bulkhead and quota handle already closed + preserve exact identity/evidence required by disposition + set nextDisposition; set nextAuthorization = NONE; continue + evaluate ordinary pure eligibility without acquiring a token + if ineligible -> return mapped rejection/failure + set nextDisposition; set nextAuthorization = NONE; continue +``` + + + +`INITIAL_ATTEMPT`는 retry disposition이 아니며 ordinary reason token을 소비하지 않는다. 다만 +실제 initial engine handoff도 protected/root shared physical token을 하나 소비한다. 후속 state는 +`AttemptAuthorizationLease`를 정확히 하나 carrying하며 같은 disposition에서 다시 acquire하지 +않는다. +`SINGLE_USE_SOURCE`는 ordinal 0에서만 열 수 있다. + +모든 wait API는 복사된 상대 timeout이 아니라 같은 absolute `executionCutoff`와 cancellation +token을 받는다. Wait가 끝난 뒤 gate를 다시 확인하지 않고 다음 resource/network side effect를 +시작하지 않는다. Gate failure면 body handle, circuit permission, bulkhead와 quota reservation handle을 획득 +역순으로 exactly-once 정리하고 아직 bind되지 않은 `AttemptAuthorizationLease`를 `ABORTED`로 닫는다. +Uncommitted quota만 반환하고 committed provider-quota token은 어떤 결과에서도 환불하지 않는다. + +Credential refresh는 previous protected attempt의 resource를 모두 반납한 뒤에만 기다린다. +Refresh waiter cancellation은 shared refresh ownership과 분리하고, refresh 성공 직후에도 cutoff가 +지났으면 새 protected request를 시작하지 않는다. Token endpoint call은 별도 logical call이며 +그 failure를 protected destination breaker에 기록하지 않는다. + +Engine 내부도 pool/stream lease, DNS, connect, TLS와 request-write phase 사이에서 remaining +budget/cancellation을 다시 확인한다. 특히 pool/DNS wait가 cutoff 전에 시작됐다는 이유로 +cutoff 뒤 새 connection, TLS handshake나 request transmission을 시작하지 않는다. + +Engine handoff에는 raw mutable counter가 아니라 parent-scoped `NestedHttpAuthorizationBroker`를 +함께 전달한다. Cold connection에서 HTTP CONNECT가 필요하거나 TLS validation이 named HTTP +OCSP/CRL lookup을 요구하면 provider는 각 wire request 전에 broker에서 exact child lease를 얻고, +별도 child cap + shared root token + cancellation/start handoff를 통과해야 한다. Authorization +failure는 origin request를 보내지 않고 해당 proxy/TLS failure로 종료한다. Provider/JVM이 broker +밖에서 implicit CONNECT 또는 revocation HTTP request를 만들 수 있는 profile은 금지한다. + +### 20.3 Mermaid sequence + +```mermaid +sequenceDiagram + participant U as Use case + participant A as Upstream adapter + participant K as HTTP kernel + participant R as Retry loop + participant C as Circuit breaker + participant B as Attempt bulkhead + participant E as Engine/pool + participant D as Dependency + + U->>A: feature-specific request + budget + A->>K: registered operation + K->>K: validate, deadline, logical admission + loop bounded physical attempts + K->>R: next attempt decision + R->>B: acquire + B->>C: permission lease + C->>E: execute with remaining budget + E->>D: physical HTTP request + D-->>E: response/failure + E-->>C: typed attempt result + C-->>B: exact-once record/ignore/release + C-->>R: attempt outcome + end + R-->>K: completed/rejected/indeterminate + K-->>A: kernel result + A-->>U: application result +``` + +### 20.4 Resource-release order + +Attempt 종료: + +1. stop body producer/consumer; +2. close response/entity stream; +3. cancel request execution if incomplete; +4. mark connection reusable 또는 discard according to framing/cancel evidence; +5. release pool lease; +6. release uncommitted local-quota reservation; already committed wire-attempt token은 환불하지 않음; +7. derive final mapped attempt outcome; +8. complete/release `CircuitPermissionLease` exactly once; +9. release attempt bulkhead; +10. finish attempt observation; +11. resolve exhaustive attempt disposition; +12. finally release logical admission. + +Retry decision 전에 prior response와 connection lifecycle을 정리한다. + +### 20.5 Failure during cleanup + +- cleanup failure가 primary failure를 덮지 않음; +- suppressed diagnostic은 secret-safe class/code만; +- incomplete entity는 connection discard; +- double-close idempotent; +- normal cleanup은 caller deadline reserve로 bounded; +- caller deadline을 넘으면 quarantine + bounded orphan reaper; +- engine 미시작 lease는 `releasePermission`, started lease는 mapped terminal outcome으로 exactly-once + 완료; +- resource leak metric/alert; +- unknown cleanup state에서 pool reuse 금지. + +## 21. Transport engine와 provider design + +### 21.1 Engine SPI + +Spring `RestClient` 자체를 provider로 부르지 않는다. 이는 synchronous API/codec facade이고 실제 +network semantics는 request factory/engine이 결정한다. + +개념적 SPI: + +```java +interface HttpTransportEngine extends AutoCloseable { + HttpAttemptHandle start(HttpAttemptRequest request, HttpAttemptObserver observer); + HttpEngineDescriptor descriptor(); +} +``` + +`HttpAttemptHandle`: + +```text +awaitHeaders(deadline) +response() +cancel(reason) +transmissionEvidence() +completion() +``` + +`HttpAttemptResponse`: + +```text +status +validated bounded header view +protocol +remote address evidence +wire body stream +trailers completion +close/discard +``` + +Engine type은 adapter 내부에만 존재한다. + +### 21.2 Provider matrix + +| Provider ID | 용도 | 초기 level | R2 제약 | +| --- | --- | --- | --- | +| `apache-hc5-classic` | imperative JSON/H1/stream callback | R2 candidate/default | hard cancel, pool, DNS, TLS, close evidence 통과 | +| `apache-hc5-async` | stronger cancellation, HTTP/2, async streaming | R2 candidate | codec bridge와 callback lifecycle evidence | +| `jdk-httpclient` | dependency-minimal H1/H2 | R1 | explicit pool lease/capacity/DNS evidence 부족 | +| `reactor-netty` | reactive pipeline/streaming | R1 candidate | reactive contract와 cancellation/backpressure suite 분리 | +| `http3-quic` | future HTTP/3 | R0 | 별도 R3 topology/security card | + +Provider ID가 classpath detection 결과로 바뀌지 않는다. + +### 21.3 초기 reference engine + +초기 구현은 `apache-hc5-classic` + Spring `RestClient`를 reference candidate로 선택한다. + +이유: + +- 현재 imperative/virtual-thread template과 정렬; +- Spring message converter와 HTTP Service Interface 활용 가능; +- pool total/per-route/lease timeout; +- custom DNS resolver; +- TLS/mTLS/proxy; +- idle/expired eviction; +- engine lifecycle; +- request hard cancellation을 검증할 seam. + +단, classic provider가 total deadline 때 실제 I/O와 connection을 확실히 취소하지 못하면 +`httpclient-static-buffered`의 `HRES-STATIC-HARD-CANCEL` evidence를 통과하지 못하며 R2로 +표기하지 않는다. 이 경우 +`apache-hc5-async`를 reference provider로 승격한다. 문서 선택이 evidence를 대신하지 않는다. + +### 21.4 Exact engine selection + +Spring Boot `ClientHttpRequestFactoryBuilder.detect()`는 classpath에 따라 HttpComponents, Jetty, +Reactor, JDK, Simple 순으로 선택할 수 있다. Template R2는 classpath 변화가 runtime engine을 +바꾸게 두지 않는다. + +```text +provider=apache-hc5-classic + -> exact HttpComponents builder + +provider=jdk-httpclient + -> exact JDK builder +``` + +Selected provider dependency가 없으면 startup failure다. 다른 SDK가 transitive로 들어와도 +provider가 바뀌지 않는다. + +### 21.5 Boot-configured `RestClient.Builder` + +Spring Boot 4는 preconfigured prototype `RestClient.Builder`에 message converters, +appropriate request factory와 observation customization을 제공한다. + +구현 원칙: + +- injected prototype builder를 destination마다 clone; +- exact request factory/engine을 명시적으로 교체; +- Boot observation registry/customizer를 보존; +- destination-specific base URI와 status/codec policy 적용; +- mutable builder를 destination 간 재사용하지 않음; +- `RestClient.create()`/raw builder로 auto-configuration을 우회하지 않음; +- configuration test가 expected interceptor/observation/codec set을 snapshot. + +현재 모든 `RestClient.Builder` bean을 차단하는 `OutboundHttpTimeoutEnforcer`는 이 구조와 +양립하지 않는다. 이를 제거하고 architecture/build gate와 registered factory 검증으로 대체한다. + +### 21.6 Apache client hardening + +Reference configuration은 최소 다음을 명시한다. + +- `PoolingHttpClientConnectionManager`; +- `maxConnTotal`, `maxConnPerRoute`; +- connection lease timeout; +- connect timeout; +- response header timeout; +- socket/response idle semantics; +- connection TTL; +- validate-after-inactivity; +- idle/expired eviction; +- DNS resolver; +- TLS socket strategy; +- proxy route planner; +- user-token/connection state policy; +- automatic retry disabled; +- automatic redirect disabled; +- cookie management disabled; +- automatic content compression disabled unless bounded layer가 소유; +- default credentials/auth caching disabled unless named profile owns; +- hard cancellation policy; +- `Expect: 100-continue` operation-specific; +- finite max redirects even though default disabled; +- close ownership. + +Apache default는 안전 계약이 아니다. 예를 들어 connection request timeout과 redirect default가 +library release에서 바뀌거나 매우 클 수 있으므로 모두 typed setting으로 고정한다. + +### 21.7 JDK provider limits + +JDK Java 21 provider를 유지할 경우: + +- `HttpClient` handle을 destination runtime이 보유하고 close; +- explicit executor와 lifecycle; +- redirect `NEVER`; +- version fixed; +- `ProxySelector` fixed/none; +- `CookieHandler` none; +- `Authenticator` none unless profile; +- SSL context/parameters explicit; +- request timeout과 outer total deadline distinction; +- `sendAsync` future cancellation evidence; +- `jdk.httpclient.*` implementation property 사용 여부를 descriptor에 표시; +- implementation property를 per-destination pool guarantee로 과장하지 않음. + +JVM-wide property는 여러 destination의 isolation을 제공하지 않는다. + +### 21.8 Reactive provider + +`reactor-netty` 또는 WebClient provider는 다음 조건에서만 활성화한다. + +- `spring-webflux`/Reactor 타입은 adapter 내부; +- application port가 synchronous이면 blocking bridge의 cancellation/context evidence; +- event-loop에서 `.block()` 금지; +- connection provider와 loop resource lifecycle; +- max connections/pending acquire/idle/lifetime; +- response release on cancel/error; +- context propagation; +- backpressure; +- H2 stream capacity; +- 별도 readiness card. + +Classpath에 WebFlux가 있다는 이유만으로 default를 바꾸지 않는다. + +## 22. Connection pool와 capacity + +### 22.1 Pool isolation key + +기본은 destination별 pool이다. 다음이 모두 같을 때만 explicit pool group으로 공유할 수 있다. + +- scheme/authority; +- proxy route; +- TLS trust; +- mTLS client identity; +- DNS/address policy; +- protocol policy; +- credential connection-affinity requirement; +- lifecycle/rotation generation. + +Bearer token만 다른 요청은 같은 TLS pool을 공유할 수 있지만 mTLS identity가 다르면 절대 공유하지 +않는다. HTTP/2 origin coalescing도 default disabled다. + +### 22.2 Required settings + +```text +maxConnectionsTotal +maxConnectionsPerRoute +maxPendingAcquires +poolAcquireTimeout +connectionTtl +idleTimeout +validateAfterInactivity +defaultKeepAliveCap +evictionInterval +gracefulCloseTimeout +``` + +모든 값: + +- finite; +- positive/zero semantics 명시; +- cross-field validation; +- selected protocol과 consistency; +- inactive destination에서는 무시가 아니라 dead-setting detection. + +### 22.3 HTTP/1.1 capacity + +대체로 한 active request가 connection 하나를 점유한다. + +```text +usableConcurrentAttempts + <= min(maxConnectionsPerRoute, attemptBulkheadMax) +``` + +Streaming download는 callback 전체 동안 lease를 보유한다. 일반 JSON pool과 장시간 streaming +pool을 분리하지 않으면 작은 호출이 starvation될 수 있다. + +### 22.4 HTTP/2 capacity + +다음 축이 별도다. + +```text +connections +max concurrent streams per connection +locally configured stream cap +server SETTINGS limit +pending stream acquires +``` + +`connections * streams`를 무조건 usable capacity로 계산하지 않는다. Flow control, large body, +server SETTINGS, GOAWAY와 head-of-line at application layer를 고려한다. + +### 22.5 Pool acquire + +- adapter logical queue 이후 attempt permit을 얻고 pool lease 요청; +- acquire timeout = min(profile cap, remaining deadline); +- pending acquire count bound; +- timeout이면 `POOL_ACQUIRE_TIMEOUT`; +- cancelled waiter 제거; +- mutation request는 아직 전송되지 않았으므로 `NOT_SENT`; +- same logical call에서 즉시 다시 pool queue에 들어가는 retry는 default 금지; +- saturation metric/alert. + +### 22.6 Lifetime, idle와 DNS + +권장 관계: + +```text +connectionTtl <= approvedDnsPinLifetime +idleTimeout <= upstream/load-balancer idle timeout safety margin +``` + +DNS TTL이 짧아도 이미 열린 keep-alive connection은 자동으로 새 IP로 이동하지 않는다. +Connection TTL과 generation drain이 DNS rollout policy에 포함되어야 한다. + +Lifetime jitter를 사용해 모든 pod/connection이 동시에 reconnect하지 않도록 할 수 있다. Jitter +range도 bounded/deterministic test 대상이다. + +### 22.7 Keep-Alive + +Server `Keep-Alive` hint를 무제한 신뢰하지 않는다. + +```text +effectiveKeepAlive = min(serverHintIfValid, clientKeepAliveCap, connectionRemainingTtl) +``` + +Invalid/huge hint는 cap하고 low-cardinality metric을 남긴다. + +### 22.8 Validation과 stale connection + +- inactivity 후 validate; +- stale connection first-use failure; +- engine automatic retry disabled 상태의 동작; +- safe read의 retry decision은 kernel 소유; +- mutation은 stale connection이라는 추측만으로 blind retry하지 않음; +- repeated stale failures는 pool/DNS/load-balancer runbook 신호. + +### 22.9 Resource/memory budget + +Per destination capacity input: + +```text +max logical calls +max protected physical attempts/root-call total attempts +max buffered request/response/error bytes +max streaming calls +max pending acquires +max auth refresh waiters +connection buffers/TLS overhead +``` + +Template는 arbitrary high default를 주지 않는다. Deployment overlay가 workload/SLO/downstream +quota에서 산정하고 readiness validator가 machine/container bounds와 모순을 검사한다. + +### 22.10 Pool observability + +필수: + +```text +leased +available +pending +max +acquire duration +acquire timeout/reject +created/closed/expired/idle-evicted +reuse +cancel-discard +generation +``` + +Pool route나 remote IP를 unbounded tag로 쓰지 않는다. Destination/pool-profile ID만 tag한다. + +## 23. DNS, address selection와 service discovery + +### 23.1 DNS policy profile + +```text +FIXED_PUBLIC +FIXED_PRIVATE +KUBERNETES_SERVICE +SERVICE_MESH_LOOPBACK +EGRESS_PROXY_ENFORCED +DYNAMIC_PUBLIC_FETCH (separate capability) +``` + +각 profile은 allowed/forbidden address, resolver, cache, stale policy, connection TTL과 readiness +evidence가 다르다. + +### 23.2 Resolve-validate-connect binding + +SSRF 방어에서 다음 TOCTOU를 금지한다. + +```text +validate hostname resolution A +then engine independently resolves B +then connect B +``` + +필수 흐름: + +```text +canonical hostname + -> designated resolver + -> all A/AAAA answers + -> canonical binary address classification + -> policy filter + -> only approved addresses handed to connection operator + -> TLS SNI/hostname verification uses original hostname +``` + +Engine이 filter 뒤 다시 resolve하면 해당 provider는 address-pinning card를 통과하지 못한다. + +### 23.3 Address classification + +Profile에 따라 최소 다음을 명시적으로 분류한다. + +- unspecified; +- loopback; +- link-local; +- private; +- carrier-grade NAT; +- multicast; +- documentation/benchmark; +- IPv4-mapped IPv6; +- configured NAT64 well-known/network-specific prefix 안의 IPv4-embedded IPv6; +- 6to4, Teredo와 IANA special-purpose IPv6 transition address; +- IPv6 unique-local/link-local; +- cloud metadata ranges; +- organization internal CIDR; +- exact approved public/private ranges. + +String prefix/regex가 아니라 parsed binary address와 prefix math를 사용한다. Configured +translation prefix에서는 embedded IPv4를 추출해 동일 IPv4 CIDR/metadata/private 정책을 다시 +적용한다. Dynamic public fetch에서 translation prefix를 신뢰성 있게 확정하지 못하면 NAT64를 +fail-closed한다. + +`localhost`, decimal/octal/hex-like IPv4 ambiguity, shortened IPv4, trailing dot, mixed-case IDN, +IPv6 zone ID, DNS CNAME chain을 security test에 포함한다. + +### 23.4 Mixed answer policy + +Public profile에서 하나의 hostname이 public과 forbidden address를 함께 반환하면: + +- default: 전체 resolution reject; +- 일부 safe address만 선택하는 mode는 DNS poisoning/partial outage 의미를 명시한 별도 profile; +- alert와 sanitized evidence; +- raw tenant/request hostname tag 금지. + +### 23.5 TTL와 cache + +Java name resolution은 positive/negative/stale cache를 가질 수 있고 일부 default가 +implementation-specific다. R2는 JVM default를 암묵적으로 사용하지 않는다. + +정의: + +- positive TTL min/max; +- negative TTL; +- stale-if-DNS-error 허용 여부와 max stale; +- CNAME chain expiry; +- refresh jitter; +- resolution timeout; +- max concurrent resolutions; +- cache entry bound; +- config/DNS change generation. + +Fixed internal service에서 previously validated stale address를 잠깐 쓰는 availability policy는 +가능하지만 public/dynamic egress에서 stale을 허용하면 address ownership 변화 위험이 있다. + +### 23.6 DNS timeout + +Platform resolver call이 interruption/deadline을 보장하지 않으면 별도 bounded resolver executor, +resolver library 또는 proxy/service-discovery provider가 필요하다. + +- executor queue bound; +- resolution deadline; +- abandoned task bound; +- resolver shutdown; +- DNS storm coalescing; +- negative cache; +- no platform thread leak. + +Timeout wrapper만 반환하고 blocking resolver task를 계속 누적시키지 않는다. + +### 23.7 Address selection + +- approved A/AAAA 전체를 순환/정책에 따라 사용; +- one bad address가 전체 deadline을 소모하지 않도록 per-address connect cap; +- IPv4/IPv6 preference와 Happy Eyeballs 지원 여부; +- address failover도 physical connect attempt로 관측; +- mutation request 전 connect failure는 `NOT_SENT`; +- connection establishment 뒤 resend는 operation retry policy 소유. + +### 23.8 Kubernetes/service discovery + +Kubernetes Service profile: + +- expected private CIDR; +- service FQDN; +- headless/ClusterIP 구분; +- readiness/pod churn과 connection TTL; +- DNS TTL; +- multi-address load spread; +- zone/locality awareness optional; +- mesh sidecar interception 여부. + +Short service name/search suffix에 의존하지 않고 canonical name을 사용한다. + +### 23.9 Service mesh + +Sidecar가 traffic을 loopback으로 redirect하면 application이 본 peer address만으로 origin +security를 증명할 수 없다. + +Descriptor: + +```text +egress_enforcement=SERVICE_MESH +mesh_identity_policy_revision +direct_egress_blocked=true +proxy_tls_mode +``` + +Mesh policy evidence와 application destination registry를 둘 다 요구한다. Mesh가 있으니 +application URI/credential/redirect 검증을 제거하지 않는다. + +## 24. SSRF, redirect와 proxy security + +### 24.1 Threat boundary + +공격 입력: + +- path/query value; +- upstream redirect; +- pagination link; +- configured DNS; +- compromised external response; +- proxy environment; +- IDN/encoding ambiguity; +- user-controlled webhook/fetch URL; +- poisoned service discovery. + +보호 대상: + +- cloud metadata; +- loopback/admin endpoints; +- cluster control plane; +- internal service; +- Unix/file/local schemes; +- credentials in cross-origin redirect; +- proxy credentials; +- network topology. + +### 24.2 Fixed destination baseline + +- exact scheme/host/port; +- relative route only; +- no userinfo; +- no fragment; +- no raw Host override; +- resolved IP policy; +- network policy/security group; +- redirect disabled; +- system proxy disabled; +- no direct fallback from proxy. + +Application validation과 network egress policy를 함께 사용한다. + +### 24.3 Redirect manual state machine + +이 절은 future redirect card의 실행 계약이다. 현재 canonical card set에는 redirect-follow가 +없으므로 fixed-destination R2에서는 engine과 kernel redirect를 모두 disable하고, follow mode를 +설정하면 startup에서 거절한다. + +Engine auto redirect를 끄고 kernel이 처리한다. + +Hop마다: + +1. 3xx가 operation에 허용되는지 확인; +2. `Location` size/syntax/relative resolution; +3. hop/loop bound; +4. method/body rewrite rule; +5. body replayability; +6. scheme downgrade 금지; +7. target origin allowlist; +8. fresh DNS/address validation; +9. cross-origin credential/header stripping; +10. remaining deadline/retry amplification budget; +11. new physical attempt span. + +301/302가 POST를 GET으로 바꾸는 engine default에 business mutation을 맡기지 않는다. +307/308이 method를 보존해도 one-shot body는 replay하지 않는다. + +Declared 3xx는 response/connection, CB permission, bulkhead와 quota handle을 먼저 닫은 뒤 §16의 +`FOLLOW_DECLARED_REDIRECT`를 만든다. 다음 loop에서 redirect-hop +`AttemptAuthorizationLease`를 한 번 획득하고 protected/root physical ceiling, fresh DNS/SSRF, +credential/body와 deadline gate를 다시 통과한다. Redirect hop도 새 ordinal/CLIENT span과 +`http.request.resend_count`를 가진다. Current card set은 이 disposition을 생성하지 않고 startup과 +call에서 fail-closed한다. + +### 24.4 Credential stripping + +Origin이 달라지면 default 제거: + +```text +Authorization +Cookie +API key header +Idempotency-Key +signature headers +client correlation containing protected data +``` + +Same-origin이라도 operation redirect contract가 없는 credential replay는 금지한다. + +### 24.5 Proxy + +Proxy profile: + +- exact proxy scheme/host/port; +- proxy TLS 여부; +- proxy auth secret ref; +- CONNECT allowed target; +- local DNS 또는 remote DNS; +- target SNI/hostname verification; +- no `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` ambient inheritance unless explicit; +- direct fallback disabled; +- proxy readiness; +- proxy audit/egress policy revision. + +Proxy가 remote DNS를 수행하면 application의 A/AAAA 검증을 R2 evidence로 주장하지 않는다. +`EGRESS_PROXY_ENFORCED` DNS profile을 선택하면 `httpclient-egress-proxy` card가 proxy-side +address filtering을 증명해야 한다. + +Proxy 자체도 숨은 transport 설정이 아니라 first-class named dependency다. Proxy +host/DNS/address/TLS/auth generation은 exact profile tuple과 readiness evidence를 가지며 parent +origin profile이 그 fingerprint를 참조한다. Proxy dependency cycle, ambient fallback과 direct +fallback을 startup에서 거절하고 shared proxy client/pool은 owner/reference count로 닫는다. +Raw-capture contract test는 `Proxy-Authorization`이 proxy hop에만 존재하고 CONNECT tunnel 내부 +origin request, redirect와 telemetry에 절대 나타나지 않음을 검증한다. + +새 tunnel에 HTTP CONNECT wire request가 필요하면 provider는 parent broker에 +`tryAuthorizeNestedHttpRequest(PROXY_CONNECT, proxyProfileFingerprint)`를 호출한다. Lease는 +`maxProxyConnectRequestsPerRootCall` token을 한 번 소비하고 CONNECT write 직전 shared root HTTP +token과 함께 bind한다. Proxy DNS/TCP/TLS만 실패했거나 기존 tunnel을 재사용해 CONNECT를 보내지 +않으면 CONNECT lease를 만들지 않는다. Child lease/root reservation/cancellation race를 통제할 수 +없는 engine proxy mode와 ambient proxy는 release-eligible이 아니다. + +### 24.6 Dynamic public fetch + +별도 high-risk capability다. + +- no credentials; +- GET/HEAD only; +- public addresses only; +- redirect exact revalidation; +- content type/size/compression cap; +- malware/content scan; +- no cookies; +- no auth challenge; +- separate pool/quota; +- egress proxy recommended; +- audit; +- downloaded content를 trusted data로 바로 사용하지 않음. + +Target authority 자체가 attacker-controlled이므로 이 card는 standard HTTP auto-instrumentation을 +억제한다. Default telemetry는 fixed `destination_id=untrusted-fetch`, bounded operation/outcome을 +사용하는 INTERNAL span/project metric뿐이며 `server.address`, `url.full`, `url.path/query`와 +`network.peer.address`를 SDK에 넣지 않는다. Target-level telemetry가 반드시 필요하면 별도 +confidential pipeline, retention/access policy와 cardinality cap을 exact profile evidence로 +승격해야 하며 없으면 fail-closed한다. + +일반 outbound HTTP card 선택만으로 dynamic fetch가 활성화되지 않는다. + +## 25. TLS, mTLS와 certificate lifecycle + +### 25.1 TLS profiles + +```text +public-system-trust +private-ca +mutual-tls +service-mesh-plaintext-to-sidecar +local-test +``` + +Profile ID는 destination에 고정되며 caller가 선택하지 않는다. + +### 25.2 Required TLS controls + +- HTTPS required in prod except approved profile; +- endpoint identification/hostname verification; +- original hostname SNI; +- supported TLS protocol allowlist; +- algorithm/cipher constraints; +- trust source; +- client key source if mTLS; +- certificate validity; +- handshake timeout; +- ALPN policy; +- session resumption policy; +- trust/key material version; +- secret-safe errors. + +Trust-all manager, accept-all hostname verifier, expired cert ignore는 hard startup/test failure다. + +### 25.3 Spring SSL Bundles + +Spring Boot named `SslBundle`을 trust/key material source로 재사용한다. + +- destination config에는 bundle ID만; +- secret literal/password를 repository YAML에 넣지 않음; +- exact bundle existence/type validation; +- engine-specific SSL context/socket strategy 생성; +- bundle ID low-cardinality metadata; +- key/trust fingerprint raw value log 금지. + +Spring Boot의 bundle file reload가 모든 client consumer를 자동 재구성한다는 보장은 없다. +공식 문서는 reload-compatible component를 제한적으로 열거한다. HTTP client는 별도 generation +rotation을 구현하고 검증한다. + +### 25.4 Rotation과 emergency revocation + +정상 변경과 compromise/revocation을 같은 drain 정책으로 처리하지 않는다. + +`NORMAL_ROLLOVER`: + +```text +detect/receive new material version + -> validate chain/key match/expiry/hostname policy + -> build new engine + pool generation + -> optional safe probe + -> atomic registry swap + -> new calls use new generation + -> old in-flight calls drain + -> bounded timeout + -> cancel/close old pool +``` + +`EMERGENCY_REVOKE`: + +```text +verified compromise/revocation signal + -> atomically block old-generation admission + -> invalidate matching token cache, TLS sessions and pool generation + -> cancel in-flight calls when the registered emergency policy requires it + -> forbid old-generation fallback and rollback + -> close old resources with bounded quarantine/reaper + -> remain NOT_READY until a validated non-revoked generation is published +``` + +Emergency event source/authenticity, affected generation/scope, in-flight cancellation policy와 audit +receipt를 registry/runbook가 소유한다. Availability를 위해 revoked credential/trust anchor를 계속 +사용하지 않는다. In-place mutable SSLContext가 기존 connection을 새 certificate로 바꾼다고 +가정하지 않는다. + +### 25.5 mTLS + +- client certificate/key pair validation; +- expected key alias; +- permitted subject/SAN policy if required; +- distinct identity => distinct pool; +- private key file permission; +- secret rotation; +- server request가 client cert를 실제 요구하는 integration test; +- expired/not-yet-valid/wrong CA/wrong key; +- dual-certificate overlap rollout; +- certificate expiry alert. + +### 25.6 Revocation과 certificate-directed egress + +OCSP/CRL은 environment policy에 따라 optional이지만 mode를 명시한다. + +- off/soft-fail/hard-fail; +- stapling support; +- cache/concurrency/response count와 byte cap; +- privacy; +- outage failure mode. + +R2 baseline은 certificate AIA/CRLDP URI, implicit OCSP responder discovery와 LDAP/FTP retrieval을 +자동으로 따라가지 않도록 비활성화한다. 인증서가 지시한 URI는 신뢰된 outbound destination이 +아니며 JVM default network timeout은 capability total deadline이 아니다. + +Network revocation lookup을 활성화하려면 responder를 first-class named revocation dependency로 +등록한다. + +- exact HTTPS scheme/host/port allowlist와 pinned DNS/SSRF policy; +- redirect, cookie, ambient proxy와 origin credential 전파 금지; +- responder TLS/auth가 필요하면 별도 exact profile; +- handshake parent cutoff 안의 finite phase deadline; +- bounded response bytes, certificate/CRL count, cache TTL와 concurrent lookups; +- no direct fallback, no certificate-provided dynamic host; +- startup에서 JVM-global PKI/security property effective value 검증; +- lifecycle/readiness/evidence fingerprint에 responder profile 포함. + +Cache miss로 named HTTP OCSP/CRL wire request가 필요하면 TLS/provider integration은 parent broker에 +`tryAuthorizeNestedHttpRequest(REVOCATION, responderProfileFingerprint)`를 호출한다. 각 request는 +`maxRevocationHttpRequestsPerRootCall` token과 shared root HTTP token을 write 직전에 bind하며, +pre-bind deadline/cancel/validation failure에서는 network side effect 없이 lease를 abort한다. Valid +cache hit/stapled evidence에는 token을 쓰지 않는다. Trust manager/JVM이 이 hook 밖에서 responder를 +호출할 수 있으면 network revocation mode를 활성화하지 않는다. + +HTTP 200은 revocation success evidence가 아니다. OCSP는 response signature와 authorized responder +chain, exact CertID/issuer/serial, status `GOOD|REVOKED|UNKNOWN`, `producedAt`/`thisUpdate`/ +`nextUpdate`, configured max-age/clock skew와 nonce/replay policy를 검증한다. `UNKNOWN`, stale, +wrong-responder와 replayed old `GOOD`을 success로 승격하지 않는다. CRL은 issuer/signature, +distribution-point와 issuing-distribution-point scope, base/delta CRL number, `thisUpdate`/ +`nextUpdate`, indirect-CRL support policy와 freshness를 검증한다. + +Internal cache/single-flight key는 raw telemetry가 아닌 다음 bounded identity로 구성한다. + +```text +revocationProfileFingerprint ++ responderProfileFingerprint ++ issuerNameHash/issuerKeyHash ++ certificateSerialOrCrlScope ++ base/delta revision +``` + +악성 certificate가 loopback, metadata, private CIDR 또는 oversized CRL/AIA URI를 가리켜도 network +side effect가 0인지 검증한다. Soft-fail은 policy가 허용한 responder outage만 의미하며 SSRF reject, +malformed/revoked evidence를 soft success로 바꾸지 않는다. “JVM default”라고만 두고 revocation +보장을 주장하지 않는다. + +### 25.7 TLS failure semantics + +- hostname/trust/expired/revoked => permanent security/config failure, retry 금지; +- handshake timeout/reset => replay-safe operation에서 bounded retry candidate; +- repeated handshake failure가 breaker를 열어도 security alert는 별도; +- certificate 내용/subject 전체 log 금지; +- peer cert hash도 metric tag 금지. + +## 26. Authentication와 secret lifecycle + +### 26.1 Auth profile + +```text +none +api-key-header +static-bearer +oauth2-client-credentials +mutual-tls +request-signature +legacy-basic +``` + +Operation은 하나의 profile 또는 declared composition을 참조한다. + +현재 canonical card set에서 `none`, conditional `api-key-header`/`static-bearer`, +`oauth2-client-credentials`, `mutual-tls`만 각각 정해진 card/evidence로 활성화할 수 있다. +`request-signature`와 `legacy-basic`은 future card가 생길 때까지 vocabulary reservation일 뿐이며 +설정하면 startup/readiness가 실패한다. + +### 26.2 Secret reference + +Config: + +```text +secretRef +version/generation +headerName if allowlisted +scope/audience +rotation overlap +refresh skew +``` + +금지: + +- repository literal; +- env registry의 example secret; +- `toString()`에 material; +- exception/log/span/metric; +- query parameter token; +- application command에 raw secret. + +### 26.3 API key/static bearer + +- fixed allowlisted header; +- CR/LF/length validation; +- `Authorization` scheme fixed; +- attempt 직전에 inject; +- redirect 전에 strip/re-evaluate; +- generation rotation with overlap if provider permits; +- health probe가 token을 노출하지 않음. + +### 26.4 OAuth2 client credentials + +Spring Security OAuth2 client integration을 사용할 수 있지만 다음은 HTTP capability가 검증한다. + +- registration/profile ID; +- token endpoint destination; +- client authentication method; +- scope/audience allowlist; +- finite token call deadline; +- token response size/media/JSON bounds; +- child token-endpoint auth-purpose/mode exact profile fingerprint와 acyclic dependency DAG; +- exact token cache/single-flight key; +- expiry skew + refresh jitter; +- single-flight refresh; +- refresh waiter bound; +- failure/backoff; +- secret rotation; +- one replay on stale-token 401; +- no recursive use of protected destination client to fetch its own token. + +Token cache와 single-flight는 정확히 같은 key를 사용한다. + +```text +TokenCacheKey( + childProfileFingerprint, + registrationOrAuthProfileId, + credentialGeneration, + normalizedCaseSensitiveScopeSet, + audienceOrResource, + tenantOrDelegationScope +) +``` + +Scope는 provider contract에 따라 case를 바꾸지 않고 정렬/deduplicate하며 audience/resource와 +opaque tenant/delegation scope도 typed canonicalization을 거친다. 어느 축 하나라도 다르면 token, +refresh future와 failure backoff를 공유하지 않는다. Key 원문은 telemetry에 넣지 않으며 credential +normal rotation/emergency revoke 시 matching entries와 waiter를 정확히 invalidate한다. + +Token endpoint 자체도 first-class named outbound dependency이며 separate pool/resilience +group을 사용한다. 일반 origin auth와 별도의 purpose/mode를 exact tuple에 넣는다. + +```text +AuthPurpose = ORIGIN | OAUTH_TOKEN_ENDPOINT | PROXY_HOP | REVOCATION_RESPONDER +TokenEndpointAuthMode = + CLIENT_SECRET_BASIC + | CLIENT_SECRET_POST + | PRIVATE_KEY_JWT + | MTLS_CLIENT_AUTH + | NONE_WHEN_PROVIDER_EXPLICITLY_ALLOWS +``` + +OAuth token child는 `AuthPurpose=OAUTH_TOKEN_ENDPOINT`이고 exact client-auth mode, header/body +ownership, redaction, signing/mTLS와 secret generation scenario를 증명한다. OAuth의 +`CLIENT_SECRET_BASIC`은 generic origin `legacy-basic` card와 다른 protocol-owned mode다. Child가 +다시 `authMode=oauth2-client-credentials`를 선택하는 재귀만 금지하며 client authentication 자체를 +`none`으로 숨기지 않는다. + +Parent OAuth profile은 child compatibility-profile fingerprint, provider/version와 policy digest를 +참조한다. Token endpoint가 자신 또는 보호 destination의 OAuth profile로 되돌아가는 cycle과 +self-reference는 startup failure다. + +각 token network attempt는 `maxNestedCredentialRequestsPerLogicalCall`과 shared root-call total +budget을 소비한다. Shared token client/cache/pool은 owner/reference count를 가지며 마지막 parent +binding이 제거되면 refresh waiter, cache와 resource를 닫는다. No OAuth binding이면 token endpoint +resource/evidence도 0개다. Child tuple maturity/scenario가 release-eligible이 아니면 parent OAuth +profile도 release-eligible이 될 수 없다. + +### 26.5 Multi-tenant/on-behalf-of + +Per-user token을 global static client profile로 넣지 않는다. + +필요 시: + +- application/security가 opaque delegated credential reference 제공; +- adapter credential exchange; +- raw inbound token pass-through 금지; +- bounded credential cache; +- tenant/user를 metric tag로 사용 금지; +- connection pool은 bearer token별로 생성하지 않음; +- mTLS tenant identity라면 bounded separate pool/card; +- revocation/logout semantics. + +### 26.6 Request signing + +SigV4/HTTP Message Signature 등 provider-specific signer: + +- final method/URI/header/body digest 확정 후 attempt마다 sign; +- redirect 후 기존 signature 재사용 금지; +- clock skew/nonce policy; +- stable payload bytes; +- idempotency key는 attempts 간 유지; +- signing key secret ref; +- signed header canonicalization golden vector; +- proxy가 signed fields를 변형하는지 test. + +### 26.7 Basic auth + +Legacy opt-in: + +- HTTPS only; +- fixed destination; +- no preemptive cross-origin forwarding; +- credential rotation; +- log redaction; +- 별도 security waiver/readiness evidence. + +### 26.8 Auth failure + +```text +local material missing/expired +token endpoint unavailable +401 invalid token +403 insufficient scope +mTLS rejection +signature clock skew/mismatch +``` + +을 분리한다. 모든 401/403을 dependency 4xx 하나로 축소하지 않는다. + +## 27. Request body, serialization와 upload + +### 27.1 Body modes + +```text +NONE +BUFFERED_JSON +BUFFERED_BINARY +REOPENABLE_STREAM +SINGLE_USE_STREAM +MULTIPART_MANAGED +``` + +Mode는 operation catalog에 고정한다. + +### 27.2 Buffered encoding + +- adapter wire DTO만 serialize; +- canonical ObjectMapper/profile; +- max encoded bytes; +- bounded output stream으로 encode 중 limit; +- content length 계산; +- immutable byte snapshot; +- digest if required; +- retries마다 동일 bytes; +- heap budget; +- encoder exception은 internal/request contract failure, network retry 금지. + +Object를 먼저 거대한 byte array로 만든 뒤 limit을 검사하지 않는다. + +### 27.3 JSON constraints + +- maximum nesting depth; +- maximum string/name/number length; +- maximum token/document length; +- duplicate key policy; +- numeric overflow; +- polymorphic typing disabled; +- unknown field policy per API version; +- null/absent distinction; +- date/time/locale; +- UTF-8 default; +- non-finite number policy; +- golden wire snapshots. + +Inbound API ObjectMapper의 관대한 설정을 outbound wire contract에 우연히 공유하지 않는다. + +### 27.4 Reopenable stream + +개념: + +```java +interface ReopenableBodySource { + BodyHandle open(AttemptContext context); + BodyIdentity identity(); +} +``` + +`BodyHandle`: + +- content type; +- known/unknown length; +- bounded readable source/callback; +- close; +- optional checksum; +- attempt generation. + +`open()`마다 같은 semantic content를 제공해야 하며 identity/digest drift면 retry를 중단한다. + +### 27.5 One-shot + +- `maxProtectedPhysicalAttemptsPerLogicalCall=1`; +- redirect/auth challenge replay 금지; +- pool/acquire/DNS failure처럼 `NOT_SENT`가 확실한 경우에 새 open 가능 여부도 source contract가 + 명시해야 함; +- body write 시작 뒤 실패는 indeterminate mutation 가능; +- caller가 stream close를 소유하지 않도록 scoped callback 선호. + +### 27.6 Request compression + +Default disabled. + +Opt-in: + +- provider accepted encoding; +- minimum threshold; +- original/encoded size caps; +- CPU budget; +- deterministic/replayable bytes; +- signature/digest order; +- compressed size observability; +- CRIME/BREACH 같은 secret/reflection context 해당 여부. + +### 27.7 `Expect: 100-continue` + +큰 authenticated upload에서 operation opt-in. + +- interim response timeout; +- proxy/server compatibility; +- 401/413를 body 전송 전에 받을 수 있음; +- 100을 받았다는 것이 mutation 미적용 보장은 아님; +- unsupported server fallback 여부; +- test matrix. + +### 27.8 Multipart + +Optional card: + +- library-generated boundary; +- fixed part names; +- sanitized filename; +- per-part/aggregate size; +- part media type; +- header injection 방지; +- streaming/replayability; +- checksum; +- temporary spool policy; +- provider contract. + +Arbitrary caller part/header map 금지. + +### 27.9 Spooling + +Memory limit을 넘는 replayable upload에 bounded disk spool을 선택할 수 있다. + +- encrypted 또는 data classification에 맞는 persistent/temp storage; +- restrictive permission; +- quota; +- checksum; +- crash cleanup; +- symlink/path defense; +- no shared predictable filename; +- lifecycle/retention; +- fileserver/objectstorage adapter 직접 의존 금지. + +Spooling은 별도 provider-internal resource이며 기본 활성화하지 않는다. + +## 28. Response body, decoding와 download + +### 28.1 Header/status first + +Response body consumer 전에: + +1. status read; +2. header aggregate validation; +3. status mapping; +4. expected body presence; +5. content type/encoding/declared length; +6. success/error decoder selection; +7. body bound 설치; +8. consumer 호출. + +Streaming `exchange()` callback이 이 순서를 직접 구현한다. + +### 28.2 Wire와 decoded bound + +```text +maxWireBytes +maxDecodedBytes +maxExpansionRatio +``` + +를 별도로 둔다. + +`Content-Length`: + +- fast reject hint; +- missing 가능; +- 거짓/압축 표현 가능; +- actual counting을 대체하지 않음. + +Transparent engine decompression을 꺼서 counting layer가 compressed/decoded 경계를 소유하거나, +provider가 두 값을 확실히 관측하는 별도 구현을 제공한다. + +### 28.3 Content encoding + +Default accepted: + +```text +identity +gzip (explicit operation/profile) +``` + +Unknown/multiple encoding: + +- operation allowlist; +- decode chain depth; +- decoded cap; +- CPU/time budget; +- malformed/truncated handling; +- connection discard. + +Brotli/Zstd native/runtime dependency는 별도 optional card다. + +### 28.4 Buffered response + +- bounded byte load; +- media/charset validation; +- decode; +- required semantic field validation; +- no raw body retention; +- generic type support through typed decoder, not caller `Class`; +- success with empty/null body contract; +- response close in finally. + +### 28.5 Streaming callback + +개념: + +```java +interface BoundedResponseConsumer { + R consume(SafeResponseMetadata metadata, BoundedBody body, CancellationView cancellation); +} +``` + +불변식: + +- body callback scope 밖 탈출 금지; +- limit은 streaming에도 적용; +- idle/total deadline; +- early return/exception에서 close/cancel; +- `readAllBytes()`를 streaming sample로 제시하지 않음; +- callback이 application/domain `InputStream`을 반환하지 않음; +- partial output publication은 consumer workflow가 staging/commit으로 처리; +- callback result가 body bytes에 비례해 무한히 커지지 않도록 별도 contract. + +### 28.6 Partial/truncated response + +- Content-Length 미충족; +- chunk terminator 없음; +- connection reset; +- checksum mismatch; +- decompressor EOF; +- HTTP/2 reset; + +은 `RESPONSE_TRUNCATED`/`CHECKSUM_MISMATCH`다. 이미 consumer가 partial side effect를 만들었다면 +adapter/use case가 abort/cleanup할 수 있어야 한다. + +### 28.7 Range/resume + +Optional: + +- stable ETag/version required; +- `Range` + `If-Match`; +- 206 required; +- exact `Content-Range`; +- total size consistency; +- checksum; +- 200 fallback은 새 target으로 restart, 기존 partial에 append 금지; +- 416 mapping; +- provider change detection; +- same deadline/retry budget. + +### 28.8 Error response + +- separate small cap; +- status received 사실 보존; +- operation-specific error codec; +- raw provider message default 미노출; +- problem type/code allowlist; +- error decode failure가 status를 잃게 하지 않음; +- connection reuse를 위해 cap 안에서 consume하거나 discard/close. + +### 28.9 Memory budget + +Buffered mode의 limit은 개별 body만이 아니라 concurrent aggregate와 연결된다. + +```text +maxConcurrentBufferedAttempts +* (request cap + response cap + error cap + codec overhead) +``` + +Startup validator가 configured concurrency와 container/JVM budget의 명백한 모순을 거부한다. +정확한 heap overhead는 load/soak evidence로 조정한다. + +## 29. Protocol, media와 API compatibility + +### 29.1 Success status is operation-specific + +다음은 모두 가능한 정상 계약이다. + +- create: 201; +- accepted async: 202 + status reference; +- delete: 204; +- conditional read: 200/304; +- range: 206; +- empty lookup: 404 mapped absence. + +`is2xxSuccessful()` 하나로 완료를 정의하지 않는다. + +### 29.2 Media negotiation + +Operation이 고정: + +- `Accept`; +- request `Content-Type`; +- response accepted type/parameters; +- charset; +- content encoding; +- API vendor media version. + +Unexpected HTML error page를 JSON으로 decode하다 connect failure로 분류하지 않는다. + +### 29.3 API version + +Header/query/path version strategy를 operation catalog가 소유한다. Spring Boot/Framework client API +version support를 사용할 수 있지만: + +- server-side version config 자동 공유를 가정하지 않음; +- version이 operation revision과 연결; +- N/N-1 rolling compatibility; +- sunset/deprecation header monitoring; +- version 값 caller override 금지. + +### 29.4 Tolerant reader, strict semantic validation + +- unknown additive field는 configured tolerant read 가능; +- required business field missing은 failure; +- enum unknown은 explicit `UNKNOWN`/failure policy; +- numeric unit/currency/version 검증; +- null/absent/default; +- provider timestamp/clock; +- schema validation과 business mapping 분리. + +Wire DTO를 domain entity로 직접 deserialize하지 않는다. + +### 29.5 Contract tests + +가능한 evidence: + +- provider OpenAPI schema; +- captured sanitized golden fixtures; +- consumer-driven contract; +- provider sandbox; +- backward/forward fixture matrix; +- unknown field/enum; +- deprecation header. + +Mock fixture가 provider 실제 behavior와 같다는 보장은 없으므로 sandbox/nightly evidence를 +구분한다. + +### 29.6 Pagination + +Generic kernel이 자동으로 모든 `next` link를 따라가지 않는다. + +Upstream adapter가: + +- bounded max pages/items/bytes; +- total deadline; +- cursor loop detection; +- known route reconstruction; +- per-page retry; +- partial-result policy; +- consistency/snapshot token; + +을 operation 의미에 맞게 소유한다. + +### 29.7 Conditional request + +ETag/If-Match/If-None-Match: + +- opaque validator; +- weak/strong semantics; +- operation-specific storage; +- 304/412 mapping; +- credential/tenant scope; +- cache interaction; +- raw ETag metric tag 금지. + +Mutation retry safety를 위해 `If-Match`를 사용할 수 있지만 idempotency-key와 동일하지 않다. + +### 29.8 HTTP caching + +Default no transparent client cache. + +필요하면: + +- RFC caching semantics; +- auth/private response; +- `Vary`; +- freshness/revalidation; +- storage/eviction; +- tenant isolation; +- invalidation; +- cache observability; + +를 별도 cache integration card로 설계한다. HTTP engine 내부 cache가 Redis/application cache +정책을 우회하지 않는다. + +### 29.9 Informational responses와 trailers + +- 100 Continue는 upload state machine; +- 103 Early Hints는 baseline에서 application outcome 아님; +- trailers는 declared allowlist와 size cap; +- checksum trailer가 있으면 full-body completion 전 success 금지; +- unsupported interim/trailer behavior는 provider matrix에 기록. + +## 30. HTTP version policy + +### 30.1 HTTP/1.1 baseline + +초기 `apache-hc5-classic` R2 profile은 HTTP/1.1을 기본으로 한다. + +- pipelining disabled; +- finite header limits; +- request/response framing strict; +- `Content-Length` + `Transfer-Encoding` ambiguity reject; +- connection-close delimited response는 truncation ambiguity를 명시; +- stale keep-alive test; +- proxy compatibility. + +RFC 9112 framing violation과 smuggling 위험을 engine strict mode/test로 검증한다. + +### 30.2 HTTP/2 opt-in + +Provider/card requirements: + +- ALPN negotiation; +- `H2_REQUIRED` 또는 fallback policy; +- stream concurrency/pending bounds; +- connection flow control; +- server SETTINGS; +- GOAWAY last-stream handling; +- `REFUSED_STREAM` retry safety; +- RST_STREAM; +- header list limit/HPACK abuse bound; +- server push disabled; +- origin coalescing disabled/verified; +- proxy CONNECT/H2 support; +- attempt observation. + +GOAWAY/RST_STREAM을 무조건 safe retry로 보지 않는다. HTTP/2 card는 transmission evidence와 +별도로 다음 processing evidence를 제공한다. + +```text +ProcessingEvidence = CONFIRMED_NOT_PROCESSED | MAYBE_PROCESSED | UNKNOWN +``` + +`REFUSED_STREAM`과 수신한 GOAWAY의 last-stream-ID보다 큰 client stream만 RFC 9113/provider +contract가 정확히 뒷받침할 때 `CONFIRMED_NOT_PROCESSED`로 올릴 수 있다. 여러 GOAWAY를 받으면 +last-stream-ID가 증가하지 않는지 검증하고 최신 evidence를 단조롭게 반영한다. 그 밖의 +RST_STREAM/GOAWAY, connection loss와 provider가 stream ID를 노출하지 않는 경우는 +`MAYBE_PROCESSED|UNKNOWN`이다. `CONFIRMED_NOT_PROCESSED`만 §16의 `RESTART_CONFIRMED_NOT_PROCESSED` 후보를 만들며, +protocol-restart `AttemptAuthorizationLease`와 body replayability, shared physical ceiling, +root-call budget, deadline, operation opt-in을 모두 통과한다. `MAYBE_PROCESSED|UNKNOWN`은 mutation +reattempt를 만들지 않는다. HTTP/2 provider가 이 evidence를 제공하지 못하면 mutation replay를 +허용하지 않는다. + +### 30.3 Downgrade + +```text +H1_ONLY +NEGOTIATE_H2_H1 +H2_REQUIRED +``` + +를 구분한다. + +- `H2_REQUIRED`가 H1로 떨어지면 startup/readiness 또는 call failure; +- negotiated protocol metric; +- proxy/LB별 compatibility; +- silent downgrade 금지. + +### 30.4 Connection coalescing + +HTTP/2가 certificate와 DNS 조건상 여러 origin을 한 connection으로 합칠 수 있어도 default +금지한다. + +- auth/tenant/mTLS isolation; +- DNS/address policy; +- destination metrics; +- circuit breaker; +- certificate SAN; + +이 섞일 수 있기 때문이다. Opt-in은 별도 security/performance evidence가 필요하다. + +### 30.5 HTTP/3 + +R2 baseline 제외, R3 candidate. + +- QUIC/UDP egress; +- certificate/ALPN; +- connection migration; +- stream capacity; +- retry/token; +- load balancer; +- observability; +- 0-RTT replay. + +Mutation과 credential-bearing request에 0-RTT를 허용하지 않는다. HTTP/3를 boolean 하나로 +HTTP/2 profile에 추가하지 않는다. + +## 31. Observability와 privacy + +### 31.1 관측 단위 + +HTTP client는 logical call과 physical attempt를 분리한다. + +| 단위 | 의미 | 기본 telemetry | +| --- | --- | --- | +| logical call | application port 호출 한 번 | application/internal span 또는 logical timer | +| physical attempt | 실제 wire request 한 번 | HTTP CLIENT span, 표준 HTTP client metric | +| admission wait | logical/physical bulkhead 진입 대기 | queue timer/gauge | +| pool lease | connection/stream capacity 대기 | pool acquire timer/gauge | +| retry delay | backoff와 `Retry-After` 대기 | retry delay timer | +| reconciliation | `INDETERMINATE` 후 상태 확인 | 별도 operation/span | + +한 physical attempt를 두 개의 CLIENT span으로 감싸지 않는다. Spring/engine instrumentation이 +CLIENT span을 만들면 adapter는 같은 attempt에 다른 CLIENT span을 추가하지 않는다. Logical call +span이 필요하면 kind를 `INTERNAL`로 두고 이름과 속성에서 attempt span과 구분한다. + +예외는 attacker-controlled authority 때문에 §24.6이 표준 HTTP instrumentation 억제를 요구하는 +`httpclient-untrusted-url-fetch`다. 이 card는 fixed bounded INTERNAL/project telemetry만 사용하며 +confidential exact profile 없이는 CLIENT `server.address|url.full`을 만들지 않는다. + +권장 span 구조: + +```text +feature use-case span + └─ http.logical / // optional INTERNAL + ├─ HTTP physical attempt 0 // CLIENT + ├─ retry wait + ├─ HTTP physical attempt 1 // CLIENT + └─ reconcile / // separate call when required +``` + +Manual retry, redirect, challenge replay가 새 wire request를 만들면 OTel HTTP semantic convention의 +`http.request.resend_count`를 실제 재전송 횟수로 기록한다. Logical call ID나 idempotency key를 +span attribute로 원문 기록하지 않는다. + +### 31.2 Trace propagation 단일 소유자 + +Propagation은 application MDC가 아니라 OTel instrumentation이 단독 소유한다. + +- `traceparent`와 `tracestate`는 current Observation/Context에서 생성한다. +- 기존 MDC `trace_id`, `span_id` 문자열로 새 `traceparent`를 조립하지 않는다. +- sampling flag를 하드코딩하지 않는다. +- B3와 W3C를 동시에 보내지 않는다. +- destination별 propagation policy가 `deny`면 trace header도 보내지 않는다. +- untrusted public fetch에는 baggage와 tenant/correlation header를 보내지 않는다. + +`docs/registries/headers.yaml`의 `traceparent`/`tracestate` 선언은 “허용된 표준 header”를 +뜻하며 custom writer 소유권을 뜻하지 않도록 registry 설명을 수정한다. 현재 +`TraceContextPropagationInterceptor`는 구현 migration에서 제거한다. + +### 31.3 Baggage와 correlation + +기본 정책: + +| Metadata | 내부 allowlisted destination | 외부 partner | untrusted URL | +| --- | --- | --- | --- | +| W3C trace context | opt-in/default allow | destination opt-in | deny | +| correlation ID | explicit allowlist | explicit contract일 때만 | deny | +| request ID | explicit allowlist | default deny | deny | +| tenant ID | destination + operation allowlist | default deny | deny | +| user principal | 금지 | 금지 | 금지 | +| auth credential | auth policy가 생성 | auth policy가 생성 | 금지 | + +`docs/registries/mdc-keys.yaml`의 `tenant_id.propagation=[http,...]`는 모든 HTTP 요청으로의 +무조건 전파가 아니다. HTTP client policy가 destination/operation allowlist를 적용한다는 설명과 +tenant leakage contract test를 registry migration에 함께 반영한다. + +### 31.4 Metrics + +표준 physical-attempt metric과 project logical-call metric을 분리한다. + +| Metric | 단위 | 의미 | +| --- | --- | --- | +| `http.client.request.duration` | seconds | 표준 physical attempt duration | +| `dependency.client.requests` | seconds | 기존 registry 호환 logical call timer | +| proposed `http.client.logical.attempts` | count | logical call당 wire attempt 수 | +| proposed `http.client.admission.duration` | seconds | logical/physical admission wait | +| proposed `http.client.pool.acquire.duration` | seconds | lease wait | +| proposed `http.client.pool.connections` | connections | leased/idle/pending/max 상태 | +| proposed `http.client.retry.delay` | seconds | 실제 backoff/Retry-After | +| proposed `http.client.failures` | count | stable failure code/stage | +| proposed `http.client.body.size` | bytes | direction + wire/decoded class별 bounded distribution | +| proposed `http.client.generations` | count | active/draining client generation | + +`proposed` metric은 먼저 `docs/registries/metrics.yaml`에 schema, tag cardinality, owner, +required test를 등록한 뒤 구현한다. 문서에 이름만 있고 meter가 없는 phantom metric을 만들지 +않는다. + +현재 `dependency.client.requests`는 다음 의미로 유지한다. + +- 한 logical call당 정확히 한 번; +- retry가 성공해도 attempt 수만큼 중복 기록하지 않음; +- `dependency_name=`; +- `dependency_type=http`; +- `outcome=SUCCESS|FAILURE|CIRCUIT_OPEN|TIMEOUT|REJECTED`; +- 세부 failure stage/code는 별도 bounded metric 또는 log/span; +- duration에는 admission부터 body close/release까지 포함. + +OTel HTTP metric과 compatibility metric이 같은 현상을 다른 단위로 세는 것을 dashboard에서 +명시한다. 둘을 합산하지 않는다. + +### 31.5 허용 tag와 금지 tag + +허용되는 low-cardinality dimension: + +- `destination_id`; +- `operation_id` 또는 bounded operation group; +- normalized method; +- status class, not arbitrary status text; +- stable outcome/failure code/stage; +- negotiated protocol; +- provider ID; +- `generation_role=active|draining|quarantined`와 + `rotation_outcome=success|failed|revoked` 같은 bounded enum; +- checked-in stable pool isolation ID/role; host, fingerprint, credential/TLS generation에서 동적 + 파생 금지; +- logical/physical scope. + +표준 OTel metric은 semantic convention이 요구하는 `http.request.method`, registered +`server.address`, `server.port`, bounded `error.type/status`를 사용한다. `server.address`는 +runtime Host header나 user input이 아니라 validated fixed destination registry에서 나온 값이어야 +한다. Project-specific metric은 destination/operation ID를 사용한다. + +금지: + +- raw URI, resolved IP, path variable, query; +- arbitrary host or redirect location; +- request/response header value; +- body, error body, exception message; +- token, API key, cookie, certificate subject 전체; +- tenant/user/customer/order/file ID; +- idempotency key, logical call ID; +- unbounded exception class/package. + +Operation ID와 destination ID는 checked-in registry에 존재하는 값만 meter tag로 사용할 수 있다. +Unknown 값은 호출 전에 거절하므로 `unknown-` 같은 동적 tag를 만들지 않는다. + +위 금지 목록은 metric tag와 application log에 대한 규칙이다. OTel CLIENT span은 standard가 +요구하는 `url.full`, `server.address`, `server.port`를 다룰 수 있어 별도 sanitizer contract를 +둔다. + +- user-info는 URI validation 단계에서 금지; +- query value는 모두 제거하거나 allowlisted non-sensitive key만 값 없이 남김; +- path variable은 operation template에 따라 `REDACTED`로 치환; +- fixed scheme/registered authority는 보존; +- `url.template`이 selected instrumentation에서 지원되면 low-cardinality template을 추가; +- raw header capture는 off; +- untrusted URL fetch는 별도 privacy profile/egress trace policy; +- actual credential/TLS generation ID는 metric에 넣지 않고 sanitized descriptor/rotation audit에만 + 기록; +- `network.peer.address`는 trust-zone별 explicit opt-in과 secure telemetry pipeline이 있을 때만 span에 + 기록하며 metric/log tag와 external/untrusted profile에서는 금지; +- explicit caller cancellation은 dependency error로 세지 않고 OTel status를 unset으로 유지하며 + bounded cancellation outcome만 logical telemetry에 기록. + +Sanitizer는 exporter 후처리가 아니라 span attribute 생성 경계에 위치한다. Raw path/query/header와 +unsanitized absolute URL을 OTel SDK, processor, sampler 또는 exporter에 한 번도 전달하지 않는다. +In-memory SDK/exporter test는 forbidden fixture 값이 생성된 attribute/event/link 전체에 0회 존재함을 +검증한다. + +Semantic convention 준수와 privacy가 충돌하는 selected instrumentation/version이면 조용히 raw +URL을 내보내지 않는다. Sanitized absolute URL contract를 구현하거나 해당 provider/card를 +release-eligible로 승격하지 않고 deviation을 descriptor에 명시한다. + +### 31.6 Resilience4j metric ownership + +현재 adapter가 설치하는 global `MeterFilter.DENY`는 제거 대상이다. 한 capability가 application +전체의 `resilience4j.*` meter를 차단하면 Redis, messaging 등 다른 capability의 관측 계약을 +깨뜨릴 수 있다. + +두 선택지 중 하나를 composition root에서 명시한다. + +1. registry가 허용한 Resilience4j meter를 bounded name/tag로 등록한다. +2. native binder를 사용하지 않고 HTTP adapter가 semantic logical/physical metric만 직접 + 기록한다. + +초기 구현은 2를 권장한다. Circuit breaker instance name에는 +`/`만 사용하고 operation/cardinality를 무제한 확장하지 않는다. + +### 31.7 Log policy + +한 logical call의 최종 failure는 한 번만 structured log로 남긴다. 개별 attempt는 기본적으로 +span/metric이고, debug sampling이나 security audit 사유가 있을 때만 log한다. + +필수 필드: + +```text +event=http_client_call_completed +destination_id +operation_id +outcome +failure_code? +failure_stage? +attempt_count +duration_ms +http_status_class? +protocol? +policy_revision +``` + +금지: + +- `Throwable#getMessage()` 직접 출력; +- URL/query/header/body dump; +- Authorization/cookie/idempotency key; +- redirect location 원문; +- resolved IP 원문을 일반 application log에 기록; +- TLS certificate 원문; +- OAuth token endpoint response. + +예외는 stable class category와 sanitized code로 변환한다. Stack trace는 unexpected internal +defect에만 보안 필터를 거쳐 제한적으로 남기며, partner response body를 exception message에 +포함하지 않는다. + +### 31.8 Sampling + +- failure와 `INDETERMINATE` span은 tail-sampling 후보; +- 성공 request의 high-volume span은 deployment sampling policy 적용; +- credential/PII가 들어갈 수 있는 event/body는 sampling 여부와 무관하게 기록 금지; +- attempt 수와 retry delay metric은 trace sampling과 무관하게 집계; +- debug wire logging은 production에서 불허; +- provider library의 header/body logger도 startup validation으로 비활성 확인. + +### 31.9 Dashboard와 alert + +Universal 임계값을 설계 문서에 하드코딩하지 않는다. Destination별 checked-in SLO가 다음 +signal과 연결되어야 한다. + +- logical success/availability; +- physical attempt amplification; +- timeout stage distribution; +- p50/p95/p99 logical/attempt latency; +- admission/pool wait와 saturation; +- circuit state/half-open result; +- DNS/TLS/auth failure; +- decoded-size/truncation reject; +- `INDETERMINATE` mutation count와 reconciliation age; +- readiness state와 client generation drain; +- retry budget exhaustion. + +Alert는 traffic이 없는 상태와 100% 성공을 구분하고, optional dependency 장애를 application +liveness failure로 바꾸지 않는다. + +## 32. Health, readiness와 SLO + +### 32.1 Liveness + +Liveness는 외부 destination을 호출하지 않는다. + +- engine thread/executor 자체 deadlock을 외부 probe로 고치지 않는다; +- partner outage 때문에 pod를 반복 재시작하지 않는다; +- pool saturation도 liveness failure가 아니라 dependency/resource alert다; +- process가 자체 health endpoint에 응답 가능한지와 fatal internal state만 본다. + +### 32.2 Startup validation + +Startup 단계에서 network business call 없이 다음을 검증한다. + +- binding이 존재하는 destination/provider/card가 registry에 존재; +- operation catalog의 destination과 binding이 일치; +- URI scheme/host/port와 SSRF policy; +- timeout/pool/body/retry cross-field invariant; +- auth/TLS/proxy/DNS profile reference; +- secret reference 해석 가능성과 최소 metadata; +- ACTIVE에서 derived selected card와 exact compatibility profile이 각 registry상 모두 + `release-eligible`; +- OTel propagation owner가 하나; +- hidden redirect/retry/cookie가 disabled; +- no binding이면 resource/bean 생성이 없음. + +TLS key material parsing이나 local trust-store load는 startup에 포함할 수 있다. 실제 remote +handshake는 readiness/evidence probe다. + +### 32.3 Readiness impact + +Destination마다 다음 impact 중 하나를 선언한다. + +```text +REQUIRED_FOR_ALL_TRAFFIC +REQUIRED_FOR_CAPABILITY +OPTIONAL +``` + +의미: + +| Impact | Remote failure 시 | +| --- | --- | +| `REQUIRED_FOR_ALL_TRAFFIC` | 충분한 debounce와 rollout 보호 후 global readiness에 반영 가능 | +| `REQUIRED_FOR_CAPABILITY` | 해당 use-case routing만 unavailable/degraded, global readiness는 정책에 따름 | +| `OPTIONAL` | global readiness 유지, descriptor/alert는 degraded | + +`REQUIRED_FOR_ALL_TRAFFIC`은 아주 드물게 사용한다. External dependency 한 곳의 장애가 모든 +pod를 동시에 NotReady로 만들어 트래픽 재분배와 재시작 폭주를 유발하지 않도록 최소 failure +window, success recovery window, stale-result TTL, rollout grace를 둔다. + +### 32.4 Probe operation + +Readiness probe는 business mutation을 호출하지 않는다. + +허용: + +- documented health endpoint; +- bounded `HEAD`/`GET` metadata endpoint; +- TLS/auth handshake까지 포함하는 provider-specific safe probe; +- service mesh/passive telemetry와 조합한 cached result. + +금지: + +- order/payment/notification 생성; +- unbounded payload download; +- normal retry budget을 소모하는 aggressive probe; +- circuit breaker의 normal call 통계를 왜곡하는 probe; +- pod마다 동기화된 고주기 polling. + +Probe는 별도 operation ID, bulkhead, rate, breaker를 사용한다. Jitter와 single-flight를 적용하고 +마지막 성공/실패 시각, stale age, failure class를 descriptor로 공개한다. + +### 32.5 Capability descriptor state + +```text +DISABLED +STARTING +READY +DEGRADED +NOT_READY +DRAINING +CLOSED +``` + +Descriptor는 최소 다음을 포함한다. + +```text +capability=http-client +destination_id +binding/provider_id +selected_cards +compatibility_profile_ids +provider_maturity +card_maturities +compatibility_profile_maturities +effective_protocol +policy_revision +active_generation +dependency_profile_fingerprints +readiness_impact +state +last_probe_result/age +``` + +Secret, URI user-info, raw host for dynamic targets, IP, credential generation material은 포함하지 +않는다. + +### 32.6 SLO와 timeout budget + +Timeout 값은 “connect 1초가 흔하다” 같은 template 상수로 정하지 않는다. + +Destination별: + +```text +inbound/use-case budget + > application processing reserve + + http logical-call total deadline + + response/compensation reserve +``` + +를 checked-in SLO/profile로 검토한다. Retry p99 amplification과 pool queue까지 포함해 capacity를 +계산한다. Required dependency의 alert threshold는 해당 dependency SLO와 error budget에 +연결한다. + +## 33. Configuration design + +### 33.1 Canonical activation shape + +상위 capability platform과 같은 canonical prefix를 사용한다. + +```yaml +ca-skeleton: + capabilities: + http-client: + expected-state: ACTIVE + bindings: + partner-catalog: apache-hc5-classic + + providers: + http-client: + apache-hc5-classic: + destinations: + partner-catalog: + base-uri: ${PARTNER_CATALOG_BASE_URI} + readiness-impact: REQUIRED_FOR_CAPABILITY + protocol: H1_ONLY + operation-catalog: partner-catalog-v1 + + timeout: + total: 2s + cleanup-reserve: 100ms + minimum-attempt-budget: 200ms + pool-acquire: 100ms + dns: 250ms + connect: 300ms + tls-handshake: 500ms + request-write: 500ms + response-headers: 1s + response-idle: 500ms + + orphan-reaper: + max-workers: 2 + max-orphans: 8 + max-queued-cleanups: 8 + cleanup-timeout: 10s + + pool: + max-total: 64 + max-per-route: 32 + max-pending-acquires: 64 + connection-max-lifetime: 5m + idle-eviction: 30s + validate-after-inactivity: 5s + + admission: + max-logical-in-flight: 96 + max-physical-in-flight: 48 + max-queued-logical-calls: 32 + + amplification: + max-protected-physical-attempts-per-logical-call: 2 + max-nested-credential-requests-per-logical-call: 0 + max-reconciliation-requests-per-logical-call: 0 + max-proxy-connect-requests-per-root-call: 0 + max-revocation-http-requests-per-root-call: 0 + max-total-http-request-attempts-per-root-call: 2 + + retry: + ordinary-max-retries: 1 + initial-backoff: 50ms + maximum-backoff: 250ms + jitter-ratio: 0.30 + honor-retry-after: true + maximum-retry-after: 1s + retry-budget-ratio: 0.05 + + circuit-breaker: + policy: partner-read-v1 + + dns: + policy: fixed-internal-v1 + egress-policy: partner-catalog-fixed-v1 + + tls: + ssl-bundle: partner-catalog-client + require-https: true + + authentication: + type: none + + proxy: + mode: DIRECT_ONLY + + bounds: + request-header-bytes: 16KiB + response-header-bytes: 32KiB + buffered-request-bytes: 1MiB + buffered-response-wire-bytes: 2MiB + buffered-response-decoded-bytes: 4MiB +``` + +숫자는 schema 예시이며 production universal default가 아니다. 실제 값은 destination SLO, +provider limit, pod memory/CPU, replica 수, upstream quota를 근거로 승인한다. Secret 값은 직접 +YAML에 쓰지 않는다. 이 예제는 static buffered/safe-read baseline만 표현한다. OAuth2를 선택하는 +예제는 `httpclient-oauth2-client-credentials` maturity가 release-eligible이고 파생 selected set에 +포함된 뒤에만 유효하다. 현재 모든 card/profile이 `not-implemented`이므로 위 ACTIVE 예제는 +target configuration shape일 뿐이며 그대로는 startup/readiness를 통과하지 않는다. + +### 33.2 Activation SSOT + +- `capabilities.http-client.bindings`에 destination이 없으면 disabled다. +- provider destination 정의가 존재하는 것만으로 client를 만들지 않는다. +- 별도 `enabled` boolean을 두지 않는다. +- binding은 정확히 하나의 provider를 선택한다. +- runtime classpath가 provider를 자동 선택하지 않는다. +- binding 대상 provider/destination이 없으면 startup failure다. +- destination binding이 하나라도 있으면 resolver가 `httpclient-static-buffered` card를 + 자동 요구한다. +- binding이 0개면 derived selected card도 0개이고 descriptor는 `DISABLED`; R2를 표시하지 않는다. +- `expected-state=ACTIVE`인데 binding이 0개면 startup/readiness failure다. +- `expected-state=DISABLED`인데 binding/provider resource/derived card가 하나라도 있으면 + startup/readiness failure다. +- `DISABLED` 성공은 `DISABLED_VERIFIED`이지 R2 HTTP 성공이 아니다. + +Selected card는 사람이 여러 위치에서 중복 입력하지 않고 deterministic resolver가 계산한다. + +```text +derivedSelectedCards = + baselineCards(required by active bindings) + union operationCatalog.requiredCards + union providerMode.requiredCards +``` + +Provider-mode derivation 예: + +| Effective mode | Required card | +| --- | --- | +| any active fixed destination | `httpclient-static-buffered` | +| bounded binary body | `httpclient-static-buffered` + binary conditional scenarios | +| API key/static bearer | `httpclient-static-buffered` + exact auth conditional scenarios | +| custom server trust | `httpclient-static-buffered` + exact TLS conditional scenarios | +| idempotent/keyed mutation operation | `httpclient-idempotent-mutation` | +| non-retryable mutation operation | `httpclient-non-retryable-mutation` | +| response stream callback | `httpclient-streaming-download` | +| reopenable/one-shot upload | `httpclient-streaming-upload` | +| client certificate | `httpclient-mtls` | +| OAuth2 client credentials | `httpclient-oauth2-client-credentials` | +| required egress proxy | `httpclient-egress-proxy` | +| H2 negotiated/required | `httpclient-http2` | +| dynamic public fetch | `httpclient-untrusted-url-fetch` | +| redirect-follow, request-signature 또는 HTTP Basic | canonical card 없음 -> startup/readiness failure | + +Resolver는 card ID나 mode 축별 합집합만 검사하지 않는다. Active operation마다 전체 tuple을 +만든다. + +```text +EffectiveHttpProfileTuple( + providerId, + resolvedProviderArtifactVersion, + jdkMajor, + protocol, + requestBodyMode, + responseBodyMode, + requestCodecMediaMode, + responseCodecMediaEncodingMode, + tlsMode, + authPurpose, + authMode, + proxyMode, + redirectMode, + dnsAddressMode, + egressTrustZone, + resilienceMode, + operationSemantics, + operationPolicyRevision, + dependencyProfileFingerprints, + effectiveBehaviorDigest +) +``` + +```text +for each active operation: + tuple = resolveExactEffectiveTuple(binding, provider, operation) + compatibility = profileCompatibilityRegistry.exactMatch(tuple) + require compatibility.requiredCards == cardsRequiredByRules(tuple) + requiredScenarioSet(tuple) = + union(compatibility.requiredCards.baseScenarioIds) + union compatibility.requiredScenarioIds + union compatibility.interactionScenarioIds + require resolvedScenarioIds(tuple) == requiredScenarioSet(tuple) + +derivedSelectedCards = union(compatibility.requiredCards) +``` + +`exactMatch`는 wildcard, 축별 union, “각 축에서 하나씩 지원됨”을 허용하지 않는다. 예를 들어 +API-key + buffered를 증명한 card와 auth-none + streaming을 증명한 card가 각각 있어도 +API-key + streaming 전체 tuple entry와 interaction scenario가 없으면 실패한다. 동일 destination의 +여러 operation은 각자 tuple을 통과하고 selected card는 그 결과의 union이다. + +`effectiveBehaviorDigest`는 secret/raw endpoint를 제외한 validated immutable settings와 operation +descriptor의 canonical serialization을 SHA-256한 값이다. Timeout/pool/bounds/compression, +DNS/egress, codec/media, retry/resilience처럼 tuple의 readable dimension 밖에서 behavior를 바꾸는 +필드도 digest를 바꾼다. OAuth token endpoint, proxy와 named revocation responder 같은 child +outbound dependency의 exact compatibility-profile fingerprint도 정렬된 +`dependencyProfileFingerprints`와 digest 입력에 포함한다. Behavior schema revision이나 필드가 +추가되면 canonical serializer와 compatibility entry를 함께 갱신하며 unknown field는 실패한다. +Dependency DAG cycle/self-reference와 child maturity gap은 resource 생성 전에 실패한다. + +Release assertion registry는 expected selected card와 compatibility-profile ID를 assertion으로 +둘 수 있으나 selector가 아니다. Assertion과 derived set이 byte-for-byte 다르면 실패한다. +Destination provider definition에 +`readiness-cards`를 반복하지 않는다. + +### 33.3 Typed settings + +Spring binding model과 validated immutable runtime model을 분리한다. + +```text +@ConfigurationProperties( + prefix = "ca-skeleton.capabilities.http-client", + ignoreUnknownFields = false) +HttpClientCapabilitySelectionProperties + ExpectedCapabilityState expectedState + Map bindings + +@ConfigurationProperties( + prefix = "ca-skeleton.providers.http-client", + ignoreUnknownFields = false) +HttpClientProviderProperties + Map providers + +HttpDestinationSettingsFactory + binding + provider properties + operation catalog + card/compatibility registries + -> ValidatedHttpDestinationSettings +``` + +Runtime settings에는 raw mutable map을 남기지 않는다. `URI`, `Duration`, byte-size, enum, +validated ID, sealed auth/proxy/DNS/TLS policy로 변환한 뒤 client를 생성한다. + +### 33.4 Cross-field validation + +최소 startup failure 조건: + +1. unknown destination/provider/card/operation catalog; +2. duplicate normalized ID; +3. binding과 provider destination 불일치; +4. absolute/relative URI invariant 위반; +5. production profile의 plain HTTP; +6. URI user-info, fragment, unsafe port; +7. timeout이 zero/negative/infinite, `cleanup-reserve >= total`, 또는 execution cutoff보다 큰 + phase cap; +8. pool per-route가 total보다 큼; +9. pending acquire/admission queue가 unbounded; +10. response decoded cap이 hard process cap보다 큼; +11. compression enabled인데 wire/decoded cap 또는 ratio cap 없음; +12. ordinary retry가 1회 이상인데 retry budget/backoff upper bound 없음; +13. operation catalog보다 느슨한 config retry/redirect/header/body policy; +14. mutation semantics cross-field invariant 위반: + - `KEYED_MUTATION`: idempotency key/fingerprint/replay window/reconciliation 없음; + - `IDEMPOTENT_MUTATION`: authoritative same-intent semantics 또는 indeterminate fallback 없음; + - `NON_RETRYABLE_MUTATION`: protected physical ceiling=1/no auth·redirect replay/transmission + evidence-to-receipt 없음; + - non-retryable `NOT_SENT` restart opt-in인데 protected physical ceiling>2, body non-reopenable, + exact evidence/restart-policy/budget/scenario 없음; +15. streaming card인데 callback close/cancel evidence 없음; +16. mTLS card인데 client key/trust bundle 없음; +17. OAuth2 card인데 registration/ref와 token-call isolation 없음; +18. proxy required인데 direct fallback 허용; +19. HTTP/2 card인데 provider/proxy/TLS profile가 미지원; +20. multiple auth subtype 또는 auth type과 nested fields 불일치; +21. TLS hostname verification disabled; +22. provider hidden redirect/retry/auth/protocol resend/cookie가 enabled 또는 pre-start kernel + authorization을 우회; +23. derived selected card 또는 exact compatibility profile maturity가 `release-eligible`이 아님; +24. same canonical + legacy key 동시 사용; +25. secret literal처럼 보이는 credential 값; +26. orphan reaper worker/queue/count/timeout이 finite positive가 아니거나 physical capacity보다 + 큰 orphan을 허용; +27. `minimumAttemptBudget + cleanupReserve > totalDeadline`; +28. active operation의 full effective profile tuple이 compatibility registry에 exact match되지 + 않거나 required-card set이 requirement rules와 다르거나 + `card base ∪ compatibility required ∪ compatibility interaction` scenario set이 완전 + 일치하지 않음; +29. redirect-follow, request-signature 또는 HTTP Basic처럼 canonical card가 없는 mode가 활성화됨; +30. protected/nested-credential/reconciliation/proxy/revocation/root-call HTTP amplification + 상한이 finite가 아니거나 child HTTP request를 root budget에 포함하지 않음, 또는 exact profile의 + cold OAuth/401/proxy/revocation `minimumRequiredRootHttpAttempts`보다 root ceiling이 작음; +31. OAuth token endpoint가 exact `OAUTH_TOKEN_ENDPOINT` purpose/client-auth mode child tuple이 + 아니거나 OAuth 재귀/cycle/self-reference, child maturity/fingerprint/owner lifecycle 누락; +32. proxy가 exact named child profile이 아니거나 ambient/direct fallback, proxy credential tunnel + leakage policy 누락; +33. network revocation lookup이 named responder 없이 certificate-directed URI/JVM implicit discovery를 + 활성화하거나 SSRF/deadline/size/concurrency/effective-property 검증 누락; +34. emergency revocation profile이 revoked-generation admission/fallback을 허용하거나 replacement 전 + `NOT_READY`를 보장하지 않음. + +### 33.5 Operation policy와 configuration의 관계 + +Operation safety는 code-reviewed catalog가 상한이다. + +Configuration이 가능한 것: + +- ordinary retry와 protected/root physical-attempt 상한 감소; +- total/phase timeout 감소; +- body/header cap 감소; +- allowed status/media type 축소; +- HTTP/2를 H1로 축소; +- optional propagation 제거; +- readiness impact를 더 보수적으로 변경. + +Configuration이 불가능한 것: + +- non-idempotent operation을 retry-safe로 승격; +- one-shot body를 replayable로 선언; +- absolute dynamic URL 허용; +- new header/credential propagation 추가; +- redirect host 확대; +- response cap 확대해 hard limit 우회; +- card가 증명하지 않은 protocol/auth/body mode 활성화. + +확장이 필요하면 catalog와 readiness evidence를 같이 변경한다. + +HTTP/2 축소는 operation/provider가 `NEGOTIATE_H2_H1`을 허용할 때만 가능하다. +`H2_REQUIRED`를 H1로 낮추는 configuration은 startup failure다. + +### 33.6 Environment key + +Flattened environment key는 canonical properties에서 기계적으로 파생한다. Destination ID를 +환경 변수 key에 직접 넣어 동적 key 폭증을 만들기보다 environment-specific checked-in YAML과 +secret reference를 사용한다. 꼭 필요한 scalar override만 `verifyEnvKeys` registry에 등록한다. + +Base URI, proxy endpoint, SSL bundle/secret reference 변경은 운영 영향이 있으므로: + +- old/new sanitized descriptor diff; +- rollout strategy; +- readiness probe; +- rollback generation; + +을 요구한다. + +### 33.7 Legacy migration + +현재 `app.outbound.http.*`는 migration-only alias다. + +1. legacy만 있으면 deprecation warning과 함께 immutable legacy settings로 변환; +2. canonical과 legacy가 동시에 있으면 값이 같아도 startup failure; +3. legacy global settings는 한 destination 외에는 사용할 수 없음; +4. retry/total-deadline 등 보장하지 못하는 legacy field를 canonical 보장으로 과장하지 않음; +5. 한 release window 뒤 alias 제거; +6. 제거 전 configuration migration test와 release note 제공. + +## 34. Composition, activation과 lifecycle + +### 34.1 Composition root + +`app-bootstrap`만 다음을 수행한다. + +1. canonical binding resolve; +2. selected destination settings validation; +3. provider factory 선택; +4. credential/TLS/DNS/proxy collaborator 주입; +5. client generation 생성; +6. feature-specific application port adapter wiring; +7. descriptor/health/lifecycle 등록. + +HTTP adapter가 component scan만으로 모든 provider와 client를 자가 활성화하지 않는다. +Application은 adapter type, `RestClient`, Apache type을 알지 못한다. + +### 34.2 Zero-binding contract + +Binding이 없으면 다음이 모두 0개여야 한다. + +- engine client와 connection manager; +- pool evictor/reaper; +- DNS resolver thread/cache; +- scheduler/executor/virtual-thread owner; +- TLS/secret file watcher; +- OAuth token refresh/cache; +- readiness probe; +- circuit breaker/retry registry entry; +- HTTP capability health contributor; +- generic default HTTP bean; +- background task. + +Classpath presence만으로 `RestClient`, provider connection manager 또는 health probe가 생성되면 +composition contract failure다. + +### 34.3 Start order + +```text +bind + validate settings + -> load local TLS/auth metadata + -> create engine generation + -> verify effective engine options + -> register bounded telemetry + -> run selected safe startup/readiness evidence + -> publish destination adapters + -> allow ingress readiness +``` + +Optional destination가 unavailable이면 policy에 따라 `DEGRADED`로 시작할 수 있다. Required +destination의 initial probe failure 처리에는 rollout grace와 cached state가 적용된다. + +### 34.4 Drain order + +현재 shutdown guard처럼 “outbound를 제일 먼저 닫는” 방식은 in-flight inbound request를 깨뜨린다. +종료 coordination은 상대적 순서를 명시한다. + +```text +ACTIVE + -> DRAIN_REQUESTED + stop accepting new ingress + stop scheduled/background producers + already accepted request token may still start outbound calls + -> INGRESS_DRAINED_OR_GRACE_EXPIRED + -> OUTBOUND_ADMISSION_CLOSED + reject all new logical calls + wait active logical calls/streams + -> CANCEL_REMAINING + cancel request/response handles + -> CLOSE_RESOURCES + close pool/client/executor/resolver/watchers + -> CLOSED +``` + +Spring `SmartLifecycle` phase 숫자를 이 문서에서 임의로 고정하지 않는다. Bootstrap의 ingress/ +background/outbound drain coordinator가 위 partial order를 contract test로 증명한 뒤 숫자를 +배정한다. 현재 `MAX_VALUE` guard는 이 순서를 증명하지 못하므로 교체 대상이다. + +### 34.5 Accepted-request lease + +Drain 중 허용 대상을 thread name/MDC로 추정하지 않는다. Ingress가 request lease/token을 발급하고 +그 request의 application call chain에 명시적으로 전달한다. + +- drain 전에 발급된 lease는 grace 안에서 outbound 시작 가능; +- background job은 별도 producer lease; +- grace 이후 모든 lease 만료; +- child async task가 lease lifetime을 무한 연장하지 못함; +- active lease/call/stream 수가 drain metric에 나타남. + +### 34.6 Generation swap + +다음 변경은 in-place mutation이 아니라 immutable generation 교체를 기본으로 한다. + +- certificate/trust material; +- credential/token client configuration; +- base endpoint/proxy; +- DNS policy; +- pool/protocol settings; +- operation policy revision. + +절차: + +```text +load new material + -> build generation N+1 + -> local validation + safe probe + -> atomic new-call routing swap + -> generation N drain + -> cancel on generation deadline + -> close N resources +``` + +`NORMAL_ROLLOVER`에서만 new generation 검증 실패 시 아직 유효하고 non-revoked인 old +generation을 유지하고 alert할 수 있다. `EMERGENCY_REVOKE`는 별도 transition이다. + +```text +ACTIVE(old) + -> REVOKE_REQUESTED + -> old admission blocked + matching sessions/tokens/pool invalidated + -> optional policy-driven in-flight cancellation + -> NOT_READY until validated replacement + -> ACTIVE(new) or CLOSED +``` + +Revoked generation으로의 fallback/rollback과 old-new overlap은 금지한다. Close가 지연되면 기존 +quarantine/reaper 계약으로 추적하되 new admission을 열지 않는다. “reload supported”를 Spring SSL +bundle 존재만으로 가정하지 않는다. 실제 selected engine의 live-reload 증거가 없으면 항상 +generation swap을 사용한다. + +### 34.7 Runtime reconfiguration + +Arbitrary hot reload는 R2 baseline이 아니다. 지원할 변경마다: + +- source authenticity; +- version monotonicity; +- full validation; +- generation atomicity; +- rollback; +- audit; +- concurrent call behavior; + +를 증명한다. 그렇지 않으면 deployment rollout로 변경한다. + +### 34.8 Engine resource ownership + +Provider factory는 다음 close handle을 반환한다. + +```text +HttpEngineGeneration implements AutoCloseable + engine + connection manager + executor/scheduler + DNS resolver/cache + credential/token collaborator + TLS material handle + active call/stream registry +``` + +공유 가능한 executor도 소유자와 reference counting/close order가 명확할 때만 공유한다. +JDK `HttpClient` handle을 버리고 GC에 lifecycle을 맡기지 않는다. + +## 35. Security threat model + +### 35.1 위협과 통제 + +| Threat | Preventive control | Detective/CI evidence | +| --- | --- | --- | +| User-controlled SSRF | fixed destination + relative route, scheme/host/port/CIDR policy | URI property tests, internal IP deny tests | +| DNS rebinding | resolve-validate-connect binding, every answer validation, TTL policy | scripted DNS rebind test | +| Redirect credential leak | redirect default deny, hop-by-hop state machine, cross-origin credential strip | 30x chain test | +| Proxy bypass/credential leak | named exact proxy profile, no ambient/direct fallback, hop-only auth | proxy-down/CONNECT raw-capture test | +| Header injection | typed header values, CR/LF/NUL reject, forbidden header ownership | raw request capture | +| Request smuggling | strict framing, no conflicting length/transfer encoding, engine hardening | raw byte server tests | +| Response splitting | strict header parser and count/byte limits | malformed response tests | +| Decompression bomb | wire + decoded + expansion ratio + time bound | compressed bomb test | +| Oversized/truncated body | bounded reader/stream, declared length validation, EOF state | lying length/chunk truncation test | +| TLS downgrade/MITM | HTTPS required, hostname verification, trust policy, protocol floor | wrong host/untrusted CA/old TLS tests | +| Client-key leakage | secret reference, non-exportable/file permission policy, redacted telemetry | secret scan and log capture | +| OAuth/API-key leakage | auth owner creates header, no raw caller credential, redirect stripping | capture server and log tests | +| Duplicate mutation | typed idempotency contract, same key/digest, unknown outcome reconciliation | lost-response replay test | +| Retry/auth/redirect amplification | reason budgets + protected/nested/root shared physical ceilings, `Retry-After` cap | combined resend property/concurrent outage test | +| Pool/queue exhaustion | finite admission/pending/pool bounds, active cancellation | saturation/resource test | +| Slowloris response | response-header and idle/body deadline | drip-feed test | +| Slow upload sink | write progress/deadline and cancellable producer | no-read server test | +| Unsafe deserialization | per-operation codec, media type/schema/size/depth limits | malicious payload corpus | +| Cross-tenant metadata leak | destination/operation propagation allowlist | tenant leakage matrix | +| Cookie/session bleed | cookie store disabled baseline, destination isolation | sequential identity test | +| Metric cardinality attack | registry IDs/bounded generation role only, no raw URI/host/status text | cardinality test | +| Telemetry pre-export leak | sanitize at attribute construction, peer address trust-zone gate | SDK/processor/exporter forbidden-value test | +| Log injection/secret leak | structured sanitized fields, no throwable message/body | hostile header/body log test | +| Dependency compromise | lock/checksum/SBOM/vulnerability/license/KEV gate | supply-chain CI | +| Stale certificate/secret | generation metadata, expiry alert, normal rotation drill | rotation test/runbook | +| Revoked/compromised material fallback | emergency admission block, session/token/pool invalidate, NOT_READY | emergency revoke/no-fallback test | +| Certificate-directed SSRF | automatic AIA/CRLDP/implicit OCSP off or named responder egress | malicious certificate zero-side-effect test | +| HTTP/2 coalescing leak | coalescing disabled/verified, auth/pool isolation | multi-origin H2 test | +| 0-RTT replay | HTTP/3 excluded; mutations/credentials never 0-RTT | provider policy test | + +### 35.2 Trust zones + +Destination registry가 trust zone을 선언한다. + +```text +INTERNAL_SERVICE +TRUSTED_PARTNER +PUBLIC_FIXED_ORIGIN +UNTRUSTED_FETCH +``` + +Zone은 기본 policy bundle을 고르지만 operation catalog보다 권한을 넓히지 않는다. +`UNTRUSTED_FETCH`는 baseline provider의 mode가 아니라 별도 readiness card/capability다. + +### 35.3 Dynamic URL fetch 분리 + +Image/PDF preview처럼 user-provided URL이 정말 필요하면 별도 adapter port로 둔다. + +- public IP만 허용하는 dedicated resolver/egress proxy; +- redirect hop마다 재검증; +- credential/cookie/trace/baggage zero; +- port/scheme allowlist; +- network policy로 metadata/control-plane/private CIDR 차단; +- content type sniffing과 decoded cap; +- sandbox/antivirus/timeout; +- audit와 abuse rate limit. + +초기 card `httpclient-untrusted-url-fetch`는 `not-implemented`다. Fixed-destination client에 boolean +하나로 열 수 없다. + +### 35.4 Security defaults + +- TLS/hostname verification on; +- redirect off; +- cookies off; +- engine automatic retry off; +- raw absolute URL off; +- arbitrary caller header off; +- proxy direct fallback off when proxy selected; +- wire/body logging off; +- trust-all/hostname-ignore API absent; +- unbounded buffer/queue absent; +- hidden auth challenge replay off unless carded; +- certificate pinning은 일반 default가 아니라 운영 가능한 rotation design이 있을 때만 opt-in. + +### 35.5 Network policy와 application policy + +Application SSRF 방어만으로 충분하지 않다. Deployment에는: + +- destination/proxy egress allowlist; +- cloud metadata/control-plane deny; +- DNS egress 제한; +- service account 최소 권한; +- proxy access log와 alert; +- secret volume permission; + +을 적용한다. 반대로 network policy만 믿고 raw URL을 application에서 허용하지 않는다. 두 층이 +독립적으로 실패를 막는다. + +## 36. Test strategy + +### 36.1 Test pyramid + +| Layer | 목적 | 외부 자원 | +| --- | --- | --- | +| pure unit/property | policy, parser, deadline, classification | 없음 | +| engine contract | 실제 provider wire behavior | loopback fake/raw server | +| fault integration | TCP/DNS/TLS/proxy/resource fault | pinned local containers | +| composition | binding/zero-resource/wiring/lifecycle | Spring context | +| compatibility | upstream schema/protocol fixtures | recorded/generated fixtures, no real partner | +| load/soak | pool, leak, retry amplification | isolated CI/nightly | + +실제 인터넷 partner endpoint를 CI에서 호출하지 않는다. Fake server/container는 loopback 또는 +CI private network에만 둔다. + +### 36.2 Pure unit/property tests + +최소: + +- destination/operation ID normalization; +- relative path encoding과 dot-segment/double-encoding; +- query multi-value/order/null policy; +- URI scheme/host/port/CIDR validation; +- IPv4/IPv6 mapped/obfuscated, NAT64/6to4/Teredo/special-purpose address classification; +- header CR/LF/NUL, count, byte limit; +- forbidden header ownership; +- media type and charset selection; +- status success/error mapping; +- `Retry-After` delta/date parsing, past/overflow/cap; +- retry decision predicate truth table; +- body replayability/idempotency/reconciliation matrix; +- `operationSemantics × transmissionEvidence × processingEvidence × cancellationOutcome × + responseIntegrity × responseSemanticClass × bodyReplayability` exhaustive disposition matrix; +- monotonic remaining budget and phase cap; +- jitter range with deterministic random source; +- CB record/ignore matrix; +- decoded/wire byte and ratio accounting; +- failure taxonomy exhaustive mapping; +- config cross-field validation; +- immutable `AllowedChildEdge` exact-match와 wrong-kind/wrong-child/unknown-fingerprint/cross-root lease + replay가 child/root token 소비와 wire side effect 0으로 거절되는 property; +- full effective profile tuple exact matching, card-set equality와 interaction scenario derivation; +- profile별 `minimumRequiredRootHttpAttempts`와 protected/token/reconcile/proxy/revocation cap + cross-field 계산; +- log/metric sanitizer/cardinality. + +Deadline test는 fake monotonic clock와 deterministic scheduler로 정확히 검증하고 wall clock에 +의존하지 않는다. + +Profile resolver property test는 각 축이 개별 card에서 지원되더라도 full tuple entry가 없으면 +항상 거절한다. Exact entry가 있을 때만 그 entry의 required card set과 +base/conditional/interaction scenario set을 반환하며, 등록되지 않은 cartesian product를 +생성하지 않는다. Validated behavior field 하나를 바꾸면 canonical behavior digest가 바뀌고, +새 exact compatibility entry/evidence 없이는 실패함을 mutation/property test로 검증한다. + +### 36.3 HTTP semantic contract + +`MockWebServer` 같은 programmable loopback server와 필요한 경우 raw socket fixture로: + +- all declared success status; +- 3xx default reject; +- every 4xx/5xx mapping; +- 204/HEAD no-body behavior; +- error-body drain/close cap; +- duplicate headers/trailers; +- interim 100/103; +- chunked/fixed/close-delimited framing; +- malformed status/header/framing; +- connection reuse after success/error/partial close; +- keep-alive expiry/stale connection; +- media type/charset mismatch; +- gzip/other declared encoding; +- pagination/ETag/conditional request; +- provider hidden retry/redirect/cookie disabled. + +401 stale-credential challenge, 429/503 retry control, declared 3xx와 reconciliation signal은 contract가 +요구한 header/body validation 뒤 공통 cleanup/disposition resolver를 반드시 통과하며 +completed/domain outcome으로 조기 반환되지 않는지 검증한다. Deterministic barrier로 +`VALID_HEADERS_ONLY`는 header 검증 직후 cancellation보다 먼저 response CAS를 이길 수 있고 body +drain failure가 결과를 덮지 않으며, body-required outcome은 decode/semantic 완료 전 response CAS를 +이기지 못함을 고정한다. Cancellation/failure/response-body callback 경합에서 winner와 cleanup +finalizer가 각각 정확히 하나이고 loser가 body/connection을 다시 닫지 않는지도 검증한다. + +`RestClient.exchange()` 경로에는 status handler가 자동 적용된다고 가정하지 않고 callback이 status를 +먼저 분기하는 contract test를 둔다. + +### 36.4 Deadline와 cancellation + +Server fault: + +- accept하지 않음; +- connect 후 TLS bytes 정지; +- request body를 읽지 않음; +- response header를 보내지 않음; +- body byte를 천천히 drip; +- retry response 후 긴 `Retry-After`; +- pool slot을 점유한 채 정지; +- streaming callback이 정지/예외/조기 반환. + +모든 경우: + +1. logical call이 total deadline upper bound + 작은 scheduler tolerance 안에 반환; +2. execution cutoff에서 active engine request/stream cancel/close를 시작; +3. cooperative/normal cleanup은 caller deadline `D` 안에 끝나고 connection/permit를 정확히 한 + 번 회수 또는 폐기; +4. uncooperative task는 caller를 붙잡지 않고 `D`에 quarantine되며 reusable pool로 한 번도 + 반환되지 않음; +5. quarantined physical-attempt permit는 실제 task 종료까지 유지되고 orphan count가 새 + admission capacity에 반영됨; +6. bounded orphan registry/queue/reaper가 `orphanCleanupTimeout` 안에 정리하고, 초과 시 + generation을 `DEGRADED/NOT_READY`로 전환해 새 호출을 거부; +7. retry/backoff/새 network side effect가 execution cutoff 뒤 시작되지 않음; +8. 정상 경로에는 background task/thread가 남지 않고 quarantine 경로에는 registry가 추적하지 + 않는 task/thread가 남지 않음; +9. mutation은 transmission phase에 따라 `INDETERMINATE`를 보존. + +Fake monotonic clock으로 `executionCutoff = D - cleanupReserve`와 caller return upper bound를 +분리 검증한다. Parent deadline이 이미 지난 경우 synchronous cleanup budget 0과 즉시 quarantine +경로를 검증한다. Thread interrupt, caller cancellation, shutdown cancellation도 별도 테스트한다. + +Deterministic latch로 backoff, credential/body open, local quota, physical bulkhead, circuit +permission, pool lease와 DNS wait 각각의 직후 deadline/cancellation을 발생시킨다. 각 경계에서 +후속 resource/network side effect count가 0이고 body/quota/permit lease가 역순으로 회수되는지 +검증한다. Engine은 pool/DNS wait 뒤 cutoff가 지나면 connect/TLS/write를 시작하지 않아야 한다. + +### 36.5 Pool와 concurrency + +- max-total/max-per-route 초과 연결 없음; +- pending acquire/queue finite; +- queue timeout은 `REJECTED_BEFORE_SEND`; +- logical bulkhead와 physical bulkhead 독립; +- virtual thread 수가 pool capacity를 우회하지 않음; +- canceled waiter가 queue에서 제거; +- response close 누락 방지; +- half-open probe가 bounded; +- two destinations/policies 간 pool isolation; +- old generation drain 중 new generation 정상; +- 반복 10k+ 호출 후 connection/thread/file-descriptor/heap 안정. + +Soak의 exact 호출 수와 시간은 CI budget에 맞추되 leak assertion과 before/after resource delta를 +artifact로 남긴다. + +### 36.6 Retry, breaker와 mutation + +Table-driven scenario: + +| Scenario | Expected | +| --- | --- | +| safe GET, connect-before-send failure | bounded retry | +| safe GET, 503 + valid `Retry-After` | capped wait 후 retry | +| safe GET, 429 beyond deadline | no retry, stable rate-limit failure | +| one-shot upload failure | no retry | +| non-retryable mutation attempt 0 | retry token 없이 한 번 실행 | +| non-retryable mutation, exact `NOT_SENT`, explicit reopenable policy | pre-send restart budget을 소비해 최대 한 번 추가 실행 | +| non-retryable mutation, `MAYBE_SENT` 이상 | `INDETERMINATE`, auth/redirect 포함 blind replay 없음 | +| non-keyed POST, response loss | `INDETERMINATE`, no blind retry | +| keyed POST, response loss | same key/digest로 inspect/reconcile | +| keyed POST payload mismatch | local reject | +| 400/401/403/404 | breaker ignore, default no retry | +| 500/502/503/504 configured | physical attempt breaker record | +| codec/oversize/programmer failure | breaker ignore | +| CB open | no engine/pool acquisition | +| half-open | exact configured permits | +| retry + redirect + auth replay 조합 | 사유별 counter와 protected physical ceiling을 넘지 않음 | +| token refresh 자체 retry + protected replay | nested credential cap과 root-call total ceiling을 넘지 않음 | +| second stale-token 401 | terminal, no second refresh/replay | +| caller/deadline/shutdown cancel + exact `NOT_SENT` | typed `RETURN_CANCELLED(reason)` | +| mutation cancel + `MAYBE_SENT+|UNKNOWN` | `RETURN_INDETERMINATE`, not cancelled/permanent | +| declared redirect future card | `FOLLOW_DECLARED_REDIRECT`, new hop lease/ordinal/common ceilings | +| H2 exact not processed + opted-in replayable body | `RESTART_CONFIRMED_NOT_PROCESSED` | +| H2 maybe/unknown processed | no mutation reattempt | + +README가 아니라 test에서 `retry -> CB(physical attempt)` 실행 순서와 exact attempt/breaker count를 +검증한다. Initial/retry/pre-send restart/same-intent/auth/redirect/protocol resend 각각이 동일 +`physicalAttemptOrdinal`과 protected/root shared token을 정확히 한 번 소비하는 property test를 +둔다. Engine hidden resend가 pre-start gate를 우회하면 provider qualification이 실패해야 한다. +Pure eligibility 호출은 모든 counter가 불변이고, 각 후속 disposition에서 +`AttemptAuthorizationLease`가 정확히 한 번 생성·bind/abort되며 auth replay token을 두 번 소비하지 +않는지 검증한다. Final protected/root reservation CAS failure는 engine start/ordinal increment 0, +reverse cleanup과 authorization abort를 보장한다. + +Authorization 직후 refresh/hop/protocol revalidation, body open, quota reject, bulkhead/CB acquire, +credential signing, local preflight와 각 common-gate failure를 하나씩 주입한다. 모든 pre-bind exit에서 +`AttemptAuthorizationLease.abort` exactly once, engine/root commit/ordinal increment 0, 획득 resource +역순 정리를 검증한다. Reconciliation은 poll wire request마다 정확히 하나의 +`NestedHttpAuthorizationLease(RECONCILIATION)`를 acquire/bind 또는 abort하고, retry를 포함한 실제 +request count가 reconciliation child cap과 shared root ceiling을 넘지 않는지 검증한다. + +`CircuitPermissionLease` contract는 다음 failure injection마다 terminal callback이 정확히 한 +번인지 검증한다. + +- `ACQUIRED` 뒤 body open/span creation/local preflight failure; +- `ACQUIRED -> STARTING` handoff 직전/직후 cancellation; +- engine synchronous start failure와 asynchronous failure, callback-before-return race; +- success, recordable failure, ignored outcome; +- execution cutoff/deadline/caller cancellation/shutdown; +- half-open concurrent permission race; +- response/connection cleanup failure; +- double completion 시도. + +Local preflight에서 engine ownership이 없거나 ignored outcome이면 `releasePermission`, +`STARTING|STARTED`의 recordable success/failure면 tracker evidence에 따라 각각 +`onSuccess`/`onError`만 호출한다. Permit leak, synchronous start race, half-open slot leak와 double +record는 모두 failure다. + +Transmission tracker는 first possible request write 직전 `NOT_SENT -> MAYBE_SENT` callback과 +단조 전이를 raw fixture로 검증한다. 각 전이 지점에서 cancel/valid response를 동시에 release해 +terminal CAS winner가 하나뿐이고 mutation `MAYBE_SENT+|UNKNOWN`은 항상 `INDETERMINATE`, exact +`NOT_SENT`만 cancelled/pre-send restart가 되는지 검증한다. Handoff 뒤 confidence unknown에서 +later authoritative response headers/completion만 progress를 refine하고 response 없이 +`NOT_SENT|SENT`로 downgrade/guess하지 않는 property를 포함한다. + +Mutation disposition은 네 operation semantics 각각에 대해 +`NOT_SENT|MAYBE_SENT|SENT|RESPONSE_STARTED|RESPONSE_COMPLETE|UNKNOWN`, processing evidence와 +cancellation winner 전부를 `VALID_COMPLETE|VALID_HEADERS_ONLY|NONE_OR_INVALID` 및 모든 +`ResponseSemanticClass`와 교차한다. Same-intent +replay/reconciliation에서는 operation attempt ID, idempotency key, fingerprint, operation ID, +tenant/credential scope가 바뀌지 않음을 검증한다. + +Attempt-number property test는 ordinal 0에서 reason token/replayability를 요구하지 않지만 +protected/root token을 한 번 소비하고, ordinal>0에서는 disposition/body/reason authorization까지 +요구함을 고정한다. Committed local provider-quota token은 success/failure/cancel 모두 환불하지 않고 +uncommitted reservation만 release하는지도 검증한다. `SINGLE_USE_SOURCE`와 +non-retryable default protected physical ceiling=1, confirmed-`NOT_SENT` pre-send restart opt-in ceiling=2, +restart/retry budget/metric 분리, `MAYBE_SENT`부터 receipt 반환을 각각 검증한다. + +현재 결함을 닫는 회귀 test도 포함한다. + +- 서로 다른 `OutboundRetryPolicy` instance를 resilience/client에 전달해도 silent retry disable이 + 재발하지 않으며 새 aggregate API는 그런 wiring 자체를 표현할 수 없음; +- response wire/decoded cap 초과는 `RESPONSE_TOO_LARGE` 계열, no-retry, breaker-ignore, + status-preserving, logical failure observation exactly once; +- oversized 4xx/5xx가 connect failure로 바뀌거나 반복 다운로드되지 않음. + +### 36.7 Streaming과 body + +- buffered path는 decoded hard cap 전 allocation을 제한; +- upload stream은 reopenable/one-shot을 구분; +- callback scope 밖 stream access 실패; +- callback return/throw/cancel 모두 response close; +- consumer가 일부만 읽고 반환해도 drain-or-discard policy; +- lying `Content-Length`; +- truncated fixed/chunked/gzip; +- compression bomb와 ratio limit; +- slow decompression/decoder deadline; +- multipart part/count/header/total limit; +- spooled temp file quota/permission/cleanup; +- range resume validator mismatch; +- error response가 success reader에 전달되지 않음. + +현재 test의 `readAllBytes()`는 production streaming proof로 인정하지 않는다. + +### 36.8 DNS와 SSRF + +두 수준으로 검증한다. + +1. scripted resolver unit test: A/AAAA/mixed/empty/timeout/rebind/TTL; +2. pinned CoreDNS/dnsmasq-like container: resolver integration, cache expiry, address rotation. + +Cases: + +- loopback, link-local, private, multicast, unspecified, IPv4-mapped IPv6; +- NAT64 well-known/custom prefix의 embedded private/metadata IPv4, 6to4, Teredo와 special-purpose; +- public + private mixed answer; +- validation 뒤 다른 address로 connect하지 않음; +- redirect hop 재해석/재검증; +- DNS timeout도 total deadline 포함; +- Kubernetes short name/search-domain ambiguity; +- proxy remote-DNS mode에서 local resolver bypass 정책. + +### 36.9 TLS와 mTLS + +Test가 매번 ephemeral CA/server/client certificate를 생성한다. + +- trusted/wrong/untrusted/expired/not-yet-valid cert; +- hostname/SAN mismatch; +- TLS protocol/cipher floor; +- server requests client cert: present/missing/wrong; +- trust/key material malformed; +- OCSP/revocation profile behavior와 effective JVM PKI property assertion; +- loopback/metadata/private/oversized AIA·CRLDP 악성 certificate에서 automatic network side effect 0; +- named revocation responder의 SSRF/redirect/body/deadline/cache/concurrency bound; +- OCSP authorized signature/CertID/status/time/nonce와 CRL issuer/signature/scope/base+delta/freshness; +- 각 named HTTP OCSP/CRL request가 revocation child cap과 parent root HTTP token을 소비; +- revocation child authorization/root reservation 실패 시 responder network side effect 0, cache hit과 + valid stapled evidence에서는 child/root token 소비 0; +- stale/wrong-responder/replayed-good/UNKNOWN OCSP와 wrong-scope/stale CRL reject, exact cache-key isolation; +- handshake timeout; +- `NORMAL_ROLLOVER` N -> N+1, atomic swap, old connection drain; +- normal rollover의 new certificate invalid이면 non-revoked old generation 유지; +- `EMERGENCY_REVOKE` old admission 즉시 차단, session/token/pool invalidate, no fallback, + replacement 전 NOT_READY와 policy-driven in-flight cancellation; +- secret/cert가 logs/JUnit/artifact에 없음. + +### 36.10 Proxy + +Pinned proxy fixture로: + +- HTTP CONNECT success/failure/auth; +- proxy DNS와 local DNS policy; +- `NO_PROXY` precedence를 사용하지 않는 explicit bypass list; +- proxy unavailable 시 direct fallback 금지; +- proxy redirect/credential stripping; +- TLS tunnel hostname verification; +- pool isolation by proxy route와 parent profile fingerprint linkage; +- proxy dependency cycle/unknown maturity/ambient or direct fallback startup reject; +- `Proxy-Authorization`이 CONNECT hop에만 존재하고 tunnel origin/telemetry에는 0회인 raw capture; +- 각 HTTP CONNECT가 proxy child cap과 parent root HTTP token을 소비; +- CONNECT child authorization/root reservation 실패 시 CONNECT/origin request write 0, existing tunnel + 재사용 시 CONNECT child/root token 소비 0; +- shared proxy owner/reference count와 shutdown/drain. + +### 36.11 HTTP/2 + +`httpclient-http2` card 선택 시: + +- ALPN H2 success; +- `H2_REQUIRED` fallback reject; +- stream concurrency/pending bound; +- SETTINGS reduction; +- GOAWAY/RST_STREAM/REFUSED_STREAM별 `CONFIRMED_NOT_PROCESSED|MAYBE_PROCESSED|UNKNOWN`과 + exact disposition/protocol-restart lease/counter; +- flow-control stall/deadline; +- header list/HPACK abuse; +- server push disabled; +- connection coalescing disabled/verified; +- proxy CONNECT compatibility; +- negotiated protocol metric. + +HTTP/1-only baseline test 성공이 HTTP/2 readiness를 의미하지 않는다. + +### 36.12 Authentication + +- API key/static bearer exact destination/header ownership와 conditional scenario derivation; +- OAuth token cache single-flight; +- token expiry skew/refresh failure; +- token endpoint 자체 exact auth-purpose/client-auth mode tuple, header/body/signature redaction, + timeout/pool/retry isolation과 acyclic dependency DAG; +- child profile fingerprint/maturity가 parent OAuth evidence에 귀속; +- nested credential cap과 root-call total amplification ceiling; +- exact cache/single-flight key 각 축의 collision/isolation과 normal/emergency invalidation; +- current generation attempt별 선택, waiter cancel detach와 shared refresh ownership; +- concurrent root callers의 creator budget lease, immutable flight deadline, owner detach, joiner + no-double-charge와 zero-waiter cancel; +- one 401 refresh replay upper bound, prior response/CB/bulkhead release와 second 401 terminal; +- replay-safe operation만 auth replay; +- redirect/cross-origin credential stripping; +- multi-tenant token cache scope; +- request-signature/HTTP Basic mode는 current card set에서 startup reject; +- future request-signature card가 추가될 때 canonicalization/body digest/replay suite; +- no secret in exception/log/span/metric/JUnit report. + +### 36.13 Observability contract + +In-memory OTel exporter와 meter registry로: + +- logical call 1개, physical span N개; +- `http.request.resend_count`; +- parent context/`tracestate` 보존; +- sampling flag 강제 변경 없음; +- destination propagation deny; +- no duplicate CLIENT span; +- compatibility timer logical once; +- failure stage/code; +- metric tag allowlist/cardinality bound와 generation role enum만 사용; +- attribute 생성 시점부터 raw URL/query/header/body/token/tenant/idempotency key가 SDK/processor/ + exporter 전체에 0회; +- `network.peer.address` trust-zone opt-in과 external/untrusted profile deny; +- untrusted fetch에서 auto HTTP instrumentation/`server.address`/`url.full`이 0회이고 fixed bounded + project telemetry만 생성; +- global MeterFilter side effect 없음; +- canceled/indeterminate span status. + +### 36.14 Composition와 lifecycle + +Spring context matrix: + +- zero binding -> zero resource/bean/health side effect; +- one/two destination exact qualified adapter; +- unknown/duplicate/conflicting canonical+legacy fail; +- provider definition only -> disabled; +- derived selected card 또는 exact compatibility profile이 `release-eligible`이 아님 -> ACTIVE + fail; +- binary/API-key/static-bearer/custom-trust mode의 exact profile tuple 또는 conditional/interaction + scenario 누락 -> resource 생성 전 fail; +- individually supported axes를 섞은 미등록 조합(예: API-key + streaming)이 compatibility entry + 없이 들어오면 resource 생성 전 fail; +- redirect-follow/request-signature/HTTP Basic처럼 canonical card 없는 mode -> resource 생성 전 + fail; +- startup order; +- ingress drain before outbound admission close; +- accepted request lease allowed during grace; +- background new call reject; +- grace expiry cancel; +- pool/executor/resolver/watcher close once; +- normal generation swap과 emergency revoke/no-fallback/NOT_READY; +- OAuth token/proxy/revocation child dependency DAG, owner/reference count와 zero-binding close; +- advertised cold OAuth/401/proxy/revocation path보다 root cap이 작으면 startup/qualification failure; +- `QUALIFICATION_ONLY`는 candidate exact tuple을 composition하지만 `ACTIVE_READY`/release assertion을 + 절대 만들지 못함; +- context restart no resource leak. + +### 36.15 Compatibility fixtures + +Destination contract fixture는: + +- request method/path/query/header/media/schema; +- success/error schema; +- tolerant optional field behavior; +- enum unknown policy; +- pagination/ETag/version; +- recorded sanitized examples; + +를 검증한다. Consumer-driven contract 도구를 쓰더라도 secret/PII가 fixture에 들어가지 않고 +provider verification 결과를 release artifact로 연결한다. + +### 36.16 Fault-tool 경계 + +Toxiproxy는 TCP latency/reset/bandwidth/toxic 검증에만 사용한다. 다음을 대신하지 않는다. + +- DNS rebinding; +- malformed HTTP framing; +- TLS certificate semantics; +- HTTP/2 stream/GOAWAY; +- application idempotency. + +각 fault에 맞는 fixture를 사용해 한 도구가 모든 보장을 증명한다고 과장하지 않는다. + +## 37. Readiness cards와 CI design + +### 37.1 Maturity registry와 selection resolver + +Canonical maturity registry: + +```text +src/config/httpclient/readiness-cards.yaml +``` + +Registry는 card 구현 성숙도와 card 자체의 base evidence만 소유한다. 지원 profile 조합을 축별 +목록으로 소유하지 않는다. + +```text +id +maturity: not-implemented | implemented-candidate | release-eligible +base-scenario-ids +required-gradle-task +required-services/images +required-runbooks +owner +``` + +Canonical exact profile compatibility registry: + +```text +src/config/httpclient/profile-compatibility.yaml +``` + +각 entry: + +```text +profile-id +maturity: not-implemented | implemented-candidate | release-eligible +tuple: + provider-id + resolved-provider-artifact-version + jdk-major + protocol + request-body-mode + response-body-mode + request-codec-media-mode + response-codec-media-encoding-mode + tls-mode + auth-purpose + auth-mode + proxy-mode + redirect-mode + dns-address-mode + egress-trust-zone + resilience-mode + operation-semantics + operation-policy-revision + dependency-profile-fingerprints + effective-behavior-digest +required-cards +required-scenario-ids +interaction-scenario-ids +owner +``` + +Tuple dimension은 모두 필수이며 wildcard/version range/omission을 허용하지 않는다. +`required-cards`는 §33.2 requirement rules와 byte-for-byte 일치해야 한다. +`interaction-scenario-ids`는 streaming + API-key, proxy + mTLS처럼 개별 축 test의 합으로 +증명할 수 없는 조합을 검증한다. 새로운 조합은 이 registry entry, interaction test와 evidence +fingerprint를 함께 추가해야 한다. + +Compatibility profile maturity: + +| Maturity | 의미 | +| --- | --- | +| `not-implemented` | planned tuple/coverage gap; ACTIVE 선택 불가 | +| `implemented-candidate` | exact tuple code와 모든 scenario가 있으나 release review/runbook 승인 전 | +| `release-eligible` | exact tuple/card composition, interaction evidence와 운영 자산이 승인됨 | + +Card와 compatibility profile maturity는 서로 대체하지 않는다. + +```text +effectiveReadiness = + all required cards release-eligible + AND exact compatibility profile release-eligible + AND exact scenario set passes +``` + +새 compatibility entry는 항상 `not-implemented`로 시작하고 evidence/review 없이 바로 +`release-eligible`로 만들 수 없다. 초기 설계 직후 모든 profile entry도 `not-implemented`다. + +Canonical release assertion registry: + +```text +src/config/httpclient/release-profile-assertions.yaml +``` + +각 entry는 release `profile-id`, 실제 deployment configuration resource/digest, +`expected-state`, `expected-selected-cards`, `expected-compatibility-profile-ids`만 가진다. +Binding/provider/card·profile maturity나 tuple 내용을 복제하지 않는다. CI는 그 configuration을 실제 +Spring property binding과 같은 resolver로 읽고 derived card/tuple set을 계산한 뒤 assertion과 +대조한다. + +`selected`는 registry state가 아니다. §33.2 resolver가 active bindings와 operation마다 exact +effective profile tuple을 만들고 compatibility entry의 required cards를 검증해 derived selected +set을 계산한다. Checked-in release assertion registry는 expected +state/card/compatibility-profile ID assertion만 소유한다. CLI flag, classpath, Docker +availability로 set을 바꾸지 않는다. + +Maturity 의미: + +| Maturity | 의미 | +| --- | --- | +| `not-implemented` | 설계/gap만 존재; release 선택 불가 | +| `implemented-candidate` | 코드와 evidence가 있으나 production release 승인 전 | +| `release-eligible` | exact supported profile evidence, review, runbook가 모두 승인됨 | + +Derived selected card 또는 exact compatibility profile이 `release-eligible`이 아니면 ACTIVE +startup/readiness/release가 실패한다. + +Candidate 승격 deadlock을 피하기 위해 test harness에만 `QUALIFICATION_ONLY` mode를 둔다. +Production과 동일한 property binding, dependency DAG, tuple resolver, behavior digest와 resource +composition을 사용하되 마지막 maturity predicate만 exact `implemented-candidate` profile을 허용한다. +이 mode는 isolated qualification task에서만 선택할 수 있고 runtime `ACTIVE_READY`, release +assertion 성공, ingress readiness 또는 production descriptor를 절대 만들지 않는다. 결과는 +candidate evidence/review 입력일 뿐이며 registry가 실제 `release-eligible`로 승인되기 전에는 +production 선택이 계속 실패한다. + +### 37.2 Canonical card set + +| Card ID | 범위 | 이번 설계 직후 실제 maturity | +| --- | --- | --- | +| `httpclient-static-buffered` | fixed destination, relative URI, H1, bounded JSON/bodiless minimum; registry-declared bounded binary/API-key/static-bearer/custom-trust conditional modes; server TLS, pool, deadline/hard cancel, safe-read retry/physical CB | `not-implemented`; first promotion target | +| `httpclient-idempotent-mutation` | declared idempotent/keyed mutation, same-identity replay, unknown outcome reconciliation | `not-implemented` | +| `httpclient-non-retryable-mutation` | one-shot mutation, transmission evidence, no blind retry, indeterminate receipt/reconciliation handoff | `not-implemented` | +| `httpclient-streaming-download` | bounded scoped streaming response | `not-implemented` | +| `httpclient-streaming-upload` | reopenable/one-shot upload, write cancellation | `not-implemented` | +| `httpclient-mtls` | client certificate, trust/key rotation | `not-implemented` | +| `httpclient-oauth2-client-credentials` | isolated token client/cache/refresh | `not-implemented` | +| `httpclient-egress-proxy` | explicit CONNECT/proxy DNS/no direct fallback | `not-implemented` | +| `httpclient-http2` | H2 negotiation, stream capacity, GOAWAY/flow control | `not-implemented` | +| `httpclient-untrusted-url-fetch` | dedicated public fetch security boundary | `not-implemented` | + +Minimum target compatibility profile ID는 예를 들어 +`httpclient-static-h1-json-auth-none-direct-safe-read.v1`처럼 bounded/stable하게 둔다. ID가 +tuple 내용을 대신하지 않으며 registry의 exact tuple과 digest가 authority다. 이번 설계 직후 이 +profile maturity도 `not-implemented`다. + +현재 R1 skeleton test를 baseline card implementation으로 승격하지 않는다. Redirect-follow, +request-signature, HTTP Basic, HTTP caching, WebSocket, SSE, HTTP/3는 이 card set에 몰래 +포함하지 않으며 활성화하면 실패한다. + +Minimum R2는 §4.2의 `httpclient-static-buffered` JSON/bodiless + auth-none profile을 뜻한다. +Optional card나 conditional mode를 선택하지 않았다는 이유로 minimum R2를 실패시키지 않지만, +선택하지 않은 기능을 R2라고 부르지 않는다. + +### 37.3 Expected deployment state + +Release profile은 정확히 하나를 선언한다. + +```text +DISABLED +ACTIVE +``` + +Truth table: + +| Expected | Binding/derived card/resource | Result | +| --- | --- | --- | +| `DISABLED` | 모두 0 | `DISABLED_VERIFIED`, HTTP R2 label 없음 | +| `DISABLED` | 하나라도 존재 | failure | +| `ACTIVE` | binding 0 | failure | +| `ACTIVE` | binding >= 1, derived cards + exact compatibility profiles release-eligible/pass | selected profile ready | +| `ACTIVE` | card/profile/evidence/consumer 하나라도 누락 | failure | + +Environment에서 key가 사라져 ACTIVE가 우연히 DISABLED green으로 바뀌지 않는다. Release gate는 +task exit code와 함께 expected state, resolved selected card set, exact compatibility profile +set, runtime descriptor state가 모두 일치하는지 검사한다. + +### 37.4 Evidence identity + +Evidence key: + +```text +compatibility-profile-id +× required-card-set +× provider-id +× resolved-provider-artifact-version +× JDK-version +× protocol +× request/response-body-mode +× request/response-codec-media-encoding-mode +× TLS/auth-purpose/auth/proxy/redirect mode +× DNS-address/egress-trust-zone/resilience mode +× operation-semantics +× operation/policy revision +× sorted child dependency profile fingerprints +× effective-behavior-digest +× evidence-scenario-id +``` + +Unique selected profile fingerprint는 위 effective values와 다음 SHA-256을 포함한다. + +- card registry; +- exact profile compatibility registry; +- release profile assertion registry와 referenced deployment configuration; +- operation catalog; +- dependency lockfiles; +- dependency verification metadata; +- test-image manifest; +- Gradle wrapper/settings; +- selected provider descriptor. + +다른 provider/version/profile의 evidence를 재사용하지 않는다. + +```text +claimedReadyProfileTuples == resolvedEffectiveProfileTuples +``` + +가 byte-for-byte 일치해야 한다. 사람이 입력하는 `evidence-revision` 문자열만으로 귀속하지 않는다. + +### 37.5 Evidence categories와 executable results + +```text +HSM = semantic +HSEC = security +HRES = deadline/resource +HLIF = lifecycle/composition +HOBS = observability/privacy +HCMP = compatibility/protocol +``` + +각 card는 여섯 category에 하나 이상의 anchor scenario를 가지지만 anchor 하나가 category 전체를 +증명하지 않는다. Card registry의 base scenario와 exact profile compatibility entry의 +required/interaction scenario가 합쳐져 §36의 해당 tuple 필수 시나리오를 열거한다. + +```text +requiredScenarioSet(tuple) = + union(for card in compatibility.requiredCards: card.baseScenarioIds) + union compatibility.requiredScenarioIds + union compatibility.interactionScenarioIds + +claimedEvidenceScenarioIds(tuple) == requiredScenarioSet(tuple) +executedScenarioIds(task) contains-all claimedEvidenceScenarioIds(tuple) +``` + +Missing/duplicate/unknown/다른 tuple로 claim한 scenario는 실패한다. Task가 무관한 추가 regression +test를 실행하는 것은 허용하지만 그 test를 profile evidence로 자동 claim하지 않는다. Executed +전체 test도 pass/no-skip 조건을 만족해야 하며, extra 실행이 missing evidence를 대체하지 못한다. + +JUnit source tag 존재만 보지 않는다. Resolved evidence tuple마다 JUnit XML과 task result에서: + +```text +tests > 0 +failures = 0 +errors = 0 +skipped = 0 +aborted = 0 +disabled = 0 +``` + +를 확인한다. Duplicate scenario ID, unknown tag, empty filter, stale fingerprint, missing XML은 +failure다. + +`not-implemented` card/profile은 planned scenario ID를 가질 수 있지만 coverage gap을 +`NOT_SELECTED_GAP`으로 보고할 뿐 readiness task를 통과하지 않는다. +`implemented-candidate`와 `release-eligible` card/profile은 exact required scenario set이 실제 +성공해야 한다. + +### 37.6 Minimum anchor matrix + +| Card | HSM | HSEC | HRES | HLIF | HOBS | HCMP | +| --- | --- | --- | --- | --- | --- | --- | +| `httpclient-static-buffered` | `HSM-STATIC-STATUS-RETRY-CB` | `HSEC-STATIC-SSRF-TLS` | `HRES-STATIC-HARD-CANCEL` | `HLIF-STATIC-ZERO-DRAIN` | `HOBS-STATIC-SPANS-PRIVACY` | `HCMP-STATIC-H1-JSON` | +| `httpclient-idempotent-mutation` | `HSM-MUTATION-UNKNOWN` | `HSEC-MUTATION-KEY` | `HRES-MUTATION-BUDGET` | `HLIF-MUTATION-RECONCILE` | `HOBS-MUTATION-REDACT` | `HCMP-MUTATION-CONTRACT` | +| `httpclient-non-retryable-mutation` | `HSM-NONRETRY-UNKNOWN` | `HSEC-NONRETRY-REDACT` | `HRES-NONRETRY-CANCEL` | `HLIF-NONRETRY-RECEIPT` | `HOBS-NONRETRY-REDACT` | `HCMP-NONRETRY-CONTRACT` | +| `httpclient-streaming-download` | `HSM-DOWNLOAD-STATUS` | `HSEC-DOWNLOAD-BOMB` | `HRES-DOWNLOAD-CANCEL` | `HLIF-DOWNLOAD-DRAIN` | `HOBS-DOWNLOAD-BOUNDS` | `HCMP-DOWNLOAD-RANGE` | +| `httpclient-streaming-upload` | `HSM-UPLOAD-REPLAY` | `HSEC-UPLOAD-MULTIPART` | `HRES-UPLOAD-WRITE` | `HLIF-UPLOAD-CLEANUP` | `HOBS-UPLOAD-REDACT` | `HCMP-UPLOAD-100` | +| `httpclient-mtls` | `HSM-MTLS-HANDSHAKE` | `HSEC-MTLS-VERIFY` | `HRES-MTLS-TIMEOUT` | `HLIF-MTLS-ROTATE` | `HOBS-MTLS-EXPIRY` | `HCMP-MTLS-VERSION` | +| `httpclient-oauth2-client-credentials` | `HSM-OAUTH-REFRESH` | `HSEC-OAUTH-LEAK` | `HRES-OAUTH-BUDGET` | `HLIF-OAUTH-CLOSE` | `HOBS-OAUTH-REDACT` | `HCMP-OAUTH-ERROR` | +| `httpclient-egress-proxy` | `HSM-PROXY-CONNECT` | `HSEC-PROXY-NOFALLBACK` | `HRES-PROXY-TIMEOUT` | `HLIF-PROXY-CLOSE` | `HOBS-PROXY-BOUNDED` | `HCMP-PROXY-TLS` | +| `httpclient-http2` | `HSM-H2-GOAWAY` | `HSEC-H2-COALESCE` | `HRES-H2-FLOW` | `HLIF-H2-DRAIN` | `HOBS-H2-PROTOCOL` | `HCMP-H2-ALPN` | +| `httpclient-untrusted-url-fetch` | `HSM-FETCH-REDIRECT` | `HSEC-FETCH-REBIND` | `HRES-FETCH-BOUND` | `HLIF-FETCH-ISOLATE` | `HOBS-FETCH-PRIVATE` | `HCMP-FETCH-CONTENT` | + +`httpclient-static-buffered`의 required scenario에는 anchor 외에도 status-first error, response +wire/decoded bound, server TLS/hostname, DNS binding, pool exhaustion, cleanup reserve/orphan +quarantine, circuit permission exact-once, retry amplification, shutdown, OTel sanitizer가 포함된다. +Bounded binary/API-key/static-bearer/custom-trust mode를 선택하면 해당 codec/size, +credential ownership·rotation·redaction 또는 trust/rotation scenario를 exact tuple에 추가한다. +Streaming/proxy/mTLS 등 다른 card와 결합되면 compatibility entry가 interaction scenario를 +추가한다. Tuple이 활성인데 exact entry나 conditional/interaction scenario가 빠지면 card task는 +실패한다. + +### 37.7 Exact proposed Gradle tasks + +Common evidence: + +```text +:adapter:outbound:httpclient:test +:adapter:outbound:httpclient:httpSemanticContractTest +:adapter:outbound:httpclient:httpSecurityTest +:adapter:outbound:httpclient:httpTlsTest +:adapter:outbound:httpclient:httpResilienceTest +:adapter:outbound:httpclient:httpDeadlineAndResourceTest +:adapter:outbound:httpclient:httpObservabilityTest +:adapter:outbound:httpclient:httpFaultTest +:adapter:outbound:httpclient:httpCompatibilityTest +:app-bootstrap:httpClientCompositionTest +verifyHttpClientProfileCompatibility +httpClientConsumerContractTest +verifyHttpClientOperationalAssets +``` + +`verifyHttpClientProfileCompatibility`는 production resolver와 같은 code path로 모든 checked-in +release configuration을 resolve한다. Missing/duplicate tuple, wildcard/range/omitted dimension, +card requirement drift, unknown/missing interaction scenario, 미등록 cross-card 조합, tuple과 +JUnit evidence fingerprint 불일치, required/claimed evidence set 불일치를 실패시킨다. 축별 +cartesian product를 자동 허용하지 않는다. + +```text +httpClientProductionReadiness.dependsOn(verifyHttpClientProfileCompatibility) +``` + +Card readiness: + +```text +httpClientStaticBufferedReadiness +httpClientIdempotentMutationReadiness +httpClientNonRetryableMutationReadiness +httpClientStreamingDownloadReadiness +httpClientStreamingUploadReadiness +httpClientMtlsReadiness +httpClientOauth2ClientCredentialsReadiness +httpClientEgressProxyReadiness +httpClientHttp2Readiness +httpClientUntrustedUrlFetchReadiness +httpClientProductionReadiness +httpClientAllImplementedCandidates +``` + +이 task는 future public build contract이며 현재 존재한다고 주장하지 않는다. Card task mapping은 +registry에 byte-for-byte 기록하고 configuration time에 생성/검증한다. Unknown task, duplicate +mapping, empty tag expression, dependency cycle은 test 실행 전 실패한다. + +`httpClientProductionReadiness`: + +1. card/compatibility/release-assertion registry schema, maturity와 IDs; +2. expected state와 binding resolve; +3. derived selected card/compatibility-profile set와 release profile assertion; +4. selected card와 exact compatibility profile이 모두 release-eligible; +5. every effective full profile tuple exact compatibility match + card-set equality + + base/required/interaction scenario set equality; +6. selected profile fingerprint별 card task; +7. ACTIVE consumer/operation contract 또는 DISABLED explicit N/A; +8. bootstrap composition/zero-resource contract; +9. operational assets; +10. architecture/config/public path/env gates; +11. supply-chain gates; +12. JUnit no-skip/zero-test assertion; +13. sanitized descriptor/evidence artifact; + +을 aggregate한다. + +`httpClientAllImplementedCandidates`는 card와 compatibility profile 중 +`implemented-candidate`/`release-eligible` 전체 exact tuple을 nightly 실행한다. +`not-implemented`는 `NOT_SELECTED_GAP`으로 보고한다. + +### 37.8 Consumer와 operational-asset gate + +`httpClientConsumerContractTest`: + +- ACTIVE binding마다 feature-specific port adapter가 하나; +- adapter operation ID와 catalog가 일치; +- request/response mapper/compatibility fixture; +- generic invoker를 controller/use case가 직접 사용하지 않음; +- sample shape이면 planned registry edge와 sample-local adapter; +- production shape이면 HTTP leaf의 direct `application-core` dependency; +- raw `repoUrl`/absolute URL이 application call에 없음. + +`verifyHttpClientOperationalAssets`: + +- selected card의 runbook path 존재; +- owner/escalation/rollback/recovery verification section; +- non-placeholder SLO/dashboard/alert registry link; +- provider upgrade and reconciliation runbook; +- descriptor/card/catalog revision link. + +DISABLED일 때만 consumer/SLO가 explicit N/A일 수 있다. Provider candidate nightly는 fixture +consumer를 사용하되 deployment ACTIVE evidence로 가장하지 않는다. + +### 37.9 Exact supply-chain gates + +```text +verifyDependencyLocks +verifyDependencyVerificationMetadataCoverage +verifyHttpClientRuntimeClasspathIsolation +verifyHttpClientTestImageManifest +generateHttpClientRuntimeSbom +generateHttpClientTestSbom +httpClientVulnerabilityLicenseKevGate +``` + +`verifyHttpClientRuntimeClasspathIsolation`은: + +- test server/Testcontainers/Toxiproxy/CA fixture가 production runtime에 없음; +- Apache/JDK/provider dependency가 selected provider policy와 일치; +- Jackson/Boot RestClient runtime이 module-isolated JSON test에 존재; +- forbidden duplicate major/engine leakage report; + +를 검증한다. + +Image manifest gate는 required service key, exact `tag@sha256`, placeholder/`latest` 금지, pull한 +image digest 일치를 검증한다. Runtime/test SBOM을 분리하고 scan 결과는 commit SHA와 SBOM digest에 +귀속한다. + +### 37.10 CI service matrix와 no-skip + +| Evidence | Fixture | +| --- | --- | +| HTTP semantics | MockWebServer + raw byte server | +| TCP fault | Toxiproxy | +| DNS | pinned authoritative DNS container/scripted resolver | +| TLS/mTLS | ephemeral CA + TLS endpoint | +| Proxy | pinned HTTP CONNECT proxy | +| HTTP/2 | ALPN/H2-capable test server | +| Observability | in-memory OTel exporter | +| Resource | process/JFR/OS counters where available | + +Container image SSOT: + +```text +src/gradle/httpclient-test-images.properties +``` + +Selected card service가 없으면 release/readiness는 실패한다. `assumeTrue`, Docker unavailable, +missing image를 skip-success로 바꾸지 않는다. Local developer task만 explicit `NOT_RUN`을 보고할 +수 있고 release evidence가 아니다. Expected test가 0개이거나 skipped/aborted/disabled가 하나라도 +있으면 selected evidence는 실패한다. + +### 37.11 Repository workflow integration + +현재 `.github/workflows/ci-quality-gates.yml`의 `release-gate`는 +`quality-gates`, `sample-off`, `gate-matrix-lint`만 집계한다. 구현 change에서 exact job을 추가한다. + +```yaml +httpclient-production-readiness: + runs-on: ubuntu-latest + steps: + # checkout + exact Java + Gradle cache setup + - name: Verify resolved HTTP client readiness from a clean runner + working-directory: src + run: >- + ./gradlew httpClientProductionReadiness + --rerun-tasks --no-build-cache --no-daemon --stacktrace + +httpclient-supply-chain: + uses: ./.github/workflows/_dependency-vulnerability-reusable.yml + with: + capability: httpclient +``` + +`release-gate`: + +```yaml +needs: + - quality-gates + - sample-off + - gate-matrix-lint + - httpclient-production-readiness + - httpclient-supply-chain + +env: + HTTPCLIENT_RESULT: ${{ needs.httpclient-production-readiness.result }} + HTTPCLIENT_SUPPLY_CHAIN_RESULT: ${{ needs.httpclient-supply-chain.result }} +``` + +Success loop에도 두 result를 포함한다. Job만 추가하고 `needs`/env/loop 중 하나라도 빠지면 blocking +아니다. + +별도 `dependency-vulnerability.yml` job은 다른 workflow의 `needs`로 직접 연결할 수 없다. 현재 +install/Trivy/KEV/license logic을 pinned reusable workflow로 추출하고 기존 workflow와 +`ci-quality-gates.yml`이 같은 implementation을 호출한다. Reusable workflow가 반환하는 commit +SHA/SBOM/scan digest가 현재 checkout과 다르면 실패한다. + +같은 change에서: + +- `.github/ci-gate-matrix.yml`에 `httpclient-production-readiness`, + `httpclient-supply-chain` 두 blocking gate 추가; +- 현재 19개 기준의 `EXPECTED_GATE_COUNT`를 21로 갱신; +- `.github/scripts/verify-gate-matrix.sh`가 blocking job set, + `release-gate.needs`, result env, shell success loop의 집합 동등성을 검증; +- workflow contract test가 unknown/missing/extra job을 실패; + +하도록 한다. + +### 37.12 Fresh-runner와 scheduled qualification + +PR/release job의 root task가 required fixture를 Testcontainers/local server로 직접 provision한다. +이전 matrix artifact가 있어도 성공을 신뢰하지 않고 clean checkout에서 +`--rerun-tasks --no-build-cache`로 selected evidence를 재실행한다. Fixture/image/secret reference가 +없으면 실패한다. + +별도 `.github/workflows/httpclient-production-readiness.yml`: + +- `schedule`; +- `workflow_dispatch`; +- release-candidate trigger; +- `QUALIFICATION_ONLY`로 resolve한 all `implemented-candidate` profile matrix와 production + resolver로 resolve한 `release-eligible` matrix; +- selected minimum/next approved provider version; +- soak/fault/compatibility; +- final clean runner `httpClientAllImplementedCandidates`; + +를 실행한다. Nightly candidate failure는 해당 card/compatibility profile maturity 승격 +blocker이며 이미 별도로 검증된 selected release profile을 다른 not-implemented card 때문에 +허위 실패로 바꾸지 않는다. + +### 37.13 Sanitized artifact + +- expected state와 `DISABLED_VERIFIED|ACTIVE_READY`; +- derived selected/maturity/gap card와 compatibility-profile set; +- exact compatibility profile IDs, full tuple와 fingerprints; +- provider/JDK/protocol/body/codec/media/encoding/TLS/auth-purpose/auth/proxy/redirect/DNS/egress/resilience/ + operation-semantics matrix와 effective-behavior digest; +- required base/conditional/interaction scenario IDs; +- scenario별 JUnit counts; +- exact Gradle command; +- fault/resource timeline; +- readiness descriptor; +- operational asset validation; +- runtime/test SBOM digest; +- vulnerability/license/KEV result; +- card/compatibility/release-assertion registry, deployment config, catalog, lock와 image-manifest + SHA-256; +- commit SHA. + +Raw URL/IP/header/body/certificate/key/token/tenant/idempotency key는 artifact에서 제거한다. Standard +OTel span sanitizer evidence는 raw span이 아니라 pass/fail과 bounded fixture IDs만 담는다. + +### 37.14 Common repository gates + +HTTP root task는 다음 existing/future exact task에 의존한다. + +```text +verifyCleanArchitectureDependencies +verifyEnvKeys +verifyPublicPathSnapshot +verifyConfigurationPropertiesProcessor +verifyDependencyLocks +verifyDependencyVerificationMetadataCoverage +verifyHttpClientProfileCompatibility +verifyHttpClientRuntimeClasspathIsolation +verifyHttpClientTestImageManifest +generateHttpClientRuntimeSbom +generateHttpClientTestSbom +verifyHttpClientOperationalAssets +``` + +`httpClientVulnerabilityLicenseKevGate`는 generated SBOM과 exact image에 대한 workflow result를 +release aggregator가 요구한다. 현재 존재하지 않는 task/job은 구현 전까지 gap이며 이 설계 +문서만으로 CI가 보장한다고 주장하지 않는다. + +## 38. Gradle, dependency와 supply chain + +### 38.1 Production dependency ownership + +초기 `apache-hc5-classic` provider 후보: + +```text +implementation project(':application-core') // production fork port implementation이 있을 때만 +implementation project(':shared-contract') +implementation project(':adapter:outbound:support') +implementation 'org.springframework.boot:spring-boot-autoconfigure' +implementation 'org.springframework.boot:spring-boot-restclient' +implementation 'org.springframework.boot:spring-boot-jackson' // JSON card/codec를 실제 소유할 때 +implementation 'org.springframework:spring-web' +implementation 'org.apache.httpcomponents.client5:httpclient5' +implementation 'io.micrometer:micrometer-observation' // 직접 API 사용 시 +implementation 'io.micrometer:micrometer-core' // 직접 meter 소유 시 +implementation 'io.github.resilience4j:resilience4j-retry' +implementation 'io.github.resilience4j:resilience4j-circuitbreaker' +implementation 'org.slf4j:slf4j-api' +annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +``` + +Spring Boot 4의 `spring-boot-restclient`가 preconfigured builder/HTTP client integration을, +`spring-boot-jackson`이 Jackson 3 runtime/auto-configuration을 명시적으로 소유한다. Inbound web +starter나 test classpath에서 우연히 들어오는 converter/codec에 의존하지 않는다. + +실제 compile/runtime API 사용을 확인해 필요 없는 dependency는 제거한다. Broad +`spring-boot-starter-*`, Resilience4j Spring starter/AOP, WebFlux starter를 convenience로 넣지 +않는다. + +두 consumer shape: + +- production fork가 HTTP leaf 안에 `application-core` port adapter를 둘 때만 HTTP leaf에 direct + `application-core` dependency 추가; +- template sample은 HTTP leaf에 sample dependency를 추가하지 않고 + `sample-portfolio`가 HTTP leaf에 의존하도록 registry/build file을 변경. + +JSON을 baseline에서 지원한다면 `spring-boot-restclient`, Boot Jackson 3와 selected +`JacksonJsonHttpMessageConverter`가 HTTP leaf isolated runtime test에서 실제 resolve되어야 한다. + +### 38.2 Version policy + +- Spring Boot BOM이 관리하는 버전은 version 없이 선언하되 실제 managed version을 CI에서 출력/ + 검증; +- Boot BOM이 Apache HC5를 관리하는지 구현 시 확인하고 미관리면 version catalog/constraint의 + 단일 SSOT 사용; +- 현재 module build의 Resilience4j `2.2.0` 세 번 직접 표기는 version catalog/provider + platform/constraint로 이동; +- 모든 configuration lockfile 갱신; +- dependency verification checksum 갱신; +- lock diff와 transitive dependency review. + +BOM 사용이 exact selected provider의 compatibility 증거를 대신하지 않는다. + +### 38.3 API leakage + +Apache, Spring HTTP, Resilience4j, Micrometer type은 모두 adapter implementation detail이다. + +- application/domain port signature에 노출 금지; +- `api` dependency로 export하지 않음; +- adapter public package 최소화; +- engine/provider는 internal package; +- feature adapter만 application port 구현; +- 예외적으로 §10.5의 framework-neutral registered-operation adapter-consumer SPI만 public; +- application/inbound가 이 SPI를 import하지 못하도록 ArchUnit; +- ArchUnit/public-path snapshot으로 leakage 검출. + +### 38.4 Optional provider isolation + +HTTP/2/async/reactive/cloud-auth provider가 추가되면 dependency set을 core leaf에 모두 넣지 않는다. +선택지: + +1. 같은 leaf의 isolated source set + runtime factory; +2. registry 변경을 동반한 별도 provider leaf; +3. app-bootstrap-only composition dependency. + +Provider가 서로 다른 Netty/Jackson/Apache major version을 강제하면 별도 leaf가 우선이다. +Classpath에 provider가 둘 존재해도 자동 fallback하지 않는다. + +### 38.5 Test dependencies + +Test-only 후보: + +```text +testImplementation MockWebServer +testImplementation Testcontainers core/JUnit integration +testImplementation Toxiproxy module or pinned proxy fixture +testImplementation property-based test library already standardized by repository +testImplementation OTel/Micrometer test exporter APIs actually used +``` + +Runtime artifact에 test server, Docker client, CA generator, proxy implementation을 포함하지 +않는다. Groovy/Spock을 유지할지 JUnit을 사용할지는 repository test convention과 실행 task +분리를 기준으로 결정하며 둘을 이유 없이 중복 도입하지 않는다. + +Module-isolation test는 inbound web/app-bootstrap의 transitive classpath 없이 HTTP leaf runtime만 +구성해 다음을 검증한다. + +- `spring-boot-restclient` auto-configuration과 prototype builder; +- exact Apache request factory; +- Boot observation/customizer preservation; +- Jackson 3 JSON converter와 Java time behavior; +- no Jackson 2 converter ambiguity; +- selected TLS bundle integration; +- missing direct runtime dependency가 startup failure로 드러남. + +### 38.6 Supply-chain gate + +- dependency locks; +- checksum/signature verification; +- SBOM with runtime/test distinction; +- CVE + CISA KEV equivalent policy; +- license allow/deny; +- transitive logging/codec/network library review; +- container image digest/SBOM; +- no repository from unapproved URL; +- no dynamic version/range/SNAPSHOT; +- dependency update compatibility suite. + +HTTP parser/TLS/compression/HTTP2 vulnerability는 high-risk로 분류하고 emergency update 때도 semantic +contract와 readiness cards를 다시 실행한다. + +### 38.7 Current dependency findings + +현재 증거: + +- `spring-web`, `spring-boot-autoconfigure`, Micrometer core, SLF4J는 직접 선언; +- Resilience4j retry/circuitbreaker/micrometer가 각각 `2.2.0`으로 직접 pin; +- production engine은 JDK `HttpClient`; +- Apache HC5 production dependency와 pool manager가 없음; +- HTTP leaf runtime에는 `spring-boot-restclient`가 없고 preconfigured Boot + `RestClient.Builder` 소유권이 없음; +- HTTP leaf runtime에는 baseline JSON을 보장할 direct Boot Jackson 3 dependency가 없음; +- HTTP leaf는 `application-core`에 직접 의존하지 않고 production semantic port를 구현하지 않음; +- test dependency는 Spock만 직접 선언; +- Resilience4j transitive runtime module이 lockfile에 존재해도 명시적 production contract를 + 의미하지 않음. + +이번 문서는 `build.gradle`을 바꾸지 않는다. 위 항목은 implementation plan의 dependency diff와 +lock refresh 대상이다. + +## 39. Implementation와 migration sequence + +### 39.1 Phase 0 — Characterization and truth-in-labeling + +작업: + +- 현재 public path/bean/config/test characterization; +- stream 4xx/5xx success bug 재현; +- retry/CB actual order test; +- `globalCallTimeout` non-cancellation test; +- JDK request-factory `readTimeout`이 logical total/byte-idle timeout을 증명하지 않는 + characterization; +- retry policy object-identity mismatch의 silent retry-disable 재현; +- response-size violation이 connect failure/no-log/retry candidate로 오분류되는 경로 재현; +- sample `RepoStatsPortClient` fixture와 raw `repoUrl` 전달 경계 기록; +- manual trace header test; +- current descriptor/README를 R0/R1로 정직하게 표시. + +Acceptance: + +- 현재 결함이 failing characterization 또는 explicit gap test로 재현; +- 기존 passing test의 증명 범위가 문서화; +- production-ready/R2 표현 없음. + +Rollback: + +- 문서/characterization만 제거 가능하나 보장 과장은 되살리지 않음. + +### 39.2 Phase 1 — Semantic ports, catalog와 disabled composition + +작업: + +- production fork는 `application-core` feature-specific port와 HTTP leaf adapter, template sample은 + sample-local port/adapter와 bounded adapter-consumer SPI 중 한 shape를 명시적으로 선택; +- sample shape의 `sample-portfolio -> adapter-outbound-httpclient` registry/build edge와 + `RepositoryCoordinates` typed input; +- adapter wire DTO/mapper; +- destination/operation/card registry; +- `expected-state`와 binding에서 exact effective profile tuples/card sets/interaction scenarios를 + 계산하고 maturity/compatibility registry와 release profile assertion을 대조; +- new card/profile maturity starts `not-implemented` and explicit promotion evidence; +- canonical typed settings; +- zero-binding composition; +- legacy configuration conflict validation. + +Acceptance: + +- application에는 HTTP/Spring/adapter type 없음; +- arbitrary URL/header API 없음; +- unmatched full profile tuple/card-composition/interaction-scenario gap은 resource 생성 전 fail; +- card-base ∪ profile-required ∪ interaction required set과 claimed evidence set equality; +- no binding zero resources; +- architecture/config binding tests 통과. + +### 39.3 Phase 2 — Apache HC5 baseline engine와 pool + +작업: + +- injected Boot `RestClient.Builder` 또는 equivalent fully configured builder; +- HTTP leaf가 `spring-boot-restclient`, baseline JSON이면 `spring-boot-jackson`, selected Apache + provider dependency를 직접 소유; +- Apache connection manager; +- finite pool/acquire/lifetime/idle settings; +- hidden retry/redirect/cookie disable; +- effective option startup assertion; +- close handle/lifecycle; +- inbound web/app-bootstrap transitive classpath 없이 Boot RestClient customization과 Jackson 3 + converter를 검증하는 module-isolation test. + +Acceptance: + +- H1 static buffered semantic/pool contract; +- no resource leak; +- selected engine exact; +- JDK fallback 없음. + +Counterargument: + +Apache classic blocking cancellation이 DNS/TLS/write/body 단계의 hard total deadline을 증명하지 +못하면 이 phase를 R2로 승인하지 않는다. 같은 Engine SPI의 Apache async provider를 reference로 +승격한다. + +### 39.4 Phase 3 — Deadline, cancellation와 resilience + +작업: + +- monotonic deadline; +- positive cleanup reserve와 execution cutoff; +- phase caps; +- cancellable engine handle; +- quarantine, bounded orphan registry/reaper와 generation degrade; +- logical/physical admission; +- explicit retry loop; +- physical-attempt CB와 exact-once `CircuitPermissionLease`; +- retry budget/`Retry-After`; +- failure taxonomy. + +Acceptance: + +- every blocked phase deadline/cancel test; +- every wait 직후 gate recheck와 cutoff 뒤 zero new resource/network side effect; +- ordinary/restart/auth/redirect/replay counter와 protected/root physical ceiling의 exact count; +- `NOT_SENT -> MAYBE_SENT` linearization과 terminal-result/cancel CAS race; +- exact attempt/CB count와 `ACQUIRED -> STARTING -> STARTED/terminal` lease handoff; +- ordinal 0은 ordinary retry token/replayability 없이 실행되지만 shared protected/root token은 + 소비하고, ordinal>0은 disposition/body/reason budget까지 검사; +- non-retryable default one attempt, explicit `NOT_SENT`-only pre-send restart with separate budget, + `MAYBE_SENT` receipt; +- no retry after deadline; +- caller return `<= D + tolerance`, normal cleanup `<= D`; +- quarantine resource no-reuse, permit hold until task termination, bounded reaper/degrade; +- pool/permit cleanup exactly once. + +### 39.5 Phase 4 — Fixed egress, DNS, SSRF와 server TLS baseline + +작업: + +- fixed destination resolve-validate-connect; +- kernel/engine redirect disabled; +- direct-only, auth-none profile; proxy/mTLS/OAuth/dynamic fetch 설정은 fail-closed; +- public-system/private-CA server TLS와 SSL bundle generation swap; +- automatic certificate-directed AIA/CRLDP/implicit OCSP egress disabled; +- propagation allowlist. + +Acceptance: + +- fixed-origin SSRF/DNS/NAT64 security matrix; +- server trust/hostname/normal rotation와 emergency revoke; +- proxy/mTLS/OAuth/redirect를 켜면 startup failure; +- no secret/tenant leak. + +### 39.6 Phase 5 — Status-first bounded buffered body baseline + +작업: + +- response integrity/semantic-class status-first 분기; +- wire/decoded/ratio cap; +- bounded buffered JSON/bodiless codec; +- error body original-status preservation; +- partial/truncated semantics; +- streaming/upload mode 설정은 fail-closed. + +Acceptance: + +- minimum static buffered card만 승격; +- compression bomb/truncation/slow body; +- response/body cleanup; +- streaming/upload card는 아직 `not-implemented`. + +### 39.7 Phase 6 — Observability, health와 readiness + +작업: + +- OTel single propagation owner; +- logical/physical telemetry; +- registry metric/header/MDC migrations; +- descriptor/readiness; +- selected-card CI/root task; +- runbooks. + +Acceptance: + +- no duplicate CLIENT span/manual header; +- cardinality/privacy test; +- selected card no-skip gate; +- zero-binding descriptor/resource contract. + +### 39.8 Phase 7 — Consumer migration and legacy removal + +작업: + +- actual feature adapters/consumers; +- old generic `OutboundHttpClient` caller 제거; +- `TraceContextPropagationInterceptor` 제거; +- `globalCallTimeout` enforcer 제거; +- legacy `app.outbound.http.*` alias 제거; +- README/runbook/public snapshot update. + +Acceptance: + +- no old bean/config/property/reference; +- every consumer uses feature port and registered operation; +- full architecture/config/public-path/CI checks. + +### 39.9 Phase 8 — Optional cards + +각 card를 별도 change로: + +- idempotent mutation; +- non-retryable mutation; +- streaming download; +- streaming upload; +- mTLS; +- OAuth2 client credentials; +- egress proxy; +- HTTP/2; +- future untrusted fetch. + +Card끼리 묶어 한 번에 R2를 선언하지 않는다. 각 card의 evidence matrix와 rollback을 독립적으로 +완성한다. + +### 39.10 Rollout + +1. shadow descriptor와 metrics만 활성; +2. one non-critical fixed destination; +3. canary pod/traffic; +4. ordinary retry disabled, protected physical ceiling 1 baseline; +5. pool/deadline/cancellation 관찰; +6. safe operation retry 점진 활성; +7. required destination 전환; +8. legacy 제거. + +자동 provider fallback/downgrade는 rollback이 아니다. Rollback은 checked-in binding/config를 +이전 generation/provider revision으로 되돌리는 명시적 배포다. + +## 40. Completion and R2 criteria + +HTTP capability를 “운영에서 바로 사용 가능” 또는 R2라고 부르려면 selected scope에서 모두 +충족해야 한다. + +1. Application use case는 feature-specific port만 의존한다. +2. Domain/application에 Spring/HTTP/engine/adapter type이 없다. +3. 모든 destination/operation/provider/card/exact profile과 OAuth token/proxy/revocation child + dependency DAG가 checked-in registry에 있고 cycle/self-reference가 없다. +4. Arbitrary absolute URL과 raw credential/header API가 baseline에 없다. +5. Operation catalog가 response integrity/semantic class, status/media/body, + retry/replay/idempotency/reconciliation 계약을 가진다. +6. Canonical binding만 activation하며 no binding은 zero resource다. +7. Exact provider가 선택되고 shared pre-start gate를 통과하지 않는 hidden + retry/redirect/auth/protocol resend/cookie가 disabled다. +8. Total deadline이 admission부터 body까지 monotonic하게 적용되고 positive cleanup reserve가 + execution cutoff와 caller-visible deadline을 분리한다. 모든 wait가 absolute cutoff를 받고 + wait 직후와 engine network phase 직전에 gate를 재검사한다. +9. Deadline/cancel 시 normal cleanup은 `D` 안에 끝나며, 끝나지 않은 task/resource는 caller + 반환을 지연하지 않고 quarantine되어 bounded reaper가 처리하고 재사용되지 않는다. +10. Initial/retry/pre-send restart/auth/redirect/same-intent/protocol restart와 nested + token/reconciliation/proxy-CONNECT/revocation HTTP request가 reason/child counter, protected + ceiling과 root-call total HTTP 상한을 함께 지키고, 모든 child wire start가 exactly-once + `NestedHttpAuthorizationLease`/root-token handoff를 거친다. Broker는 parent exact + `AllowedChildEdge`만 승인하고 bind된 child profile/operation/authority와 root가 일치하며 required + cold path가 그 안에서 실행 가능하다. +11. Ordinal 0은 ordinary reason token/replayability 없이 실행 가능하고, 후속 protected request는 pure + eligibility 뒤 exactly-once `AttemptAuthorizationLease`를 사용한다. 모든 pre-bind exit가 이를 + abort하고 engine/root commit 0을 보장한다. `NOT_SENT -> MAYBE_SENT` + first-write linearization, confidence refinement, typed cancellation disposition와 + terminal-result/cancel/handoff CAS가 test로 고정된다. +12. Mutation lost response와 `MAYBE_SENT+|UNKNOWN` cancellation은 `INDETERMINATE`와 bounded + reconciliation을 보존한다. +13. Stale-token 401은 prior resource release, exact cache key, immutable-deadline/creator-budget + single-flight, one auth lease/replay, same identity와 second-401 terminal 계약을 모두 지킨다. +14. Circuit breaker record/ignore와 physical/logical 단위, + `ACQUIRED -> STARTING -> STARTED/terminal` permission lease exact-once 완료가 test로 고정된다. +15. Admission, pool pending, connection/stream, body/temp, credential waiter와 orphan quota가 유한하다. +16. Response status/header/framing과 bounded decoder를 body consumer 전에 분기하고 body/decode + 뒤 response integrity와 semantic control outcome을 독립적으로 finalize한다. Header-authoritative + outcome은 header 검증 직후, body-required outcome은 body/semantic 검증 뒤 terminal CAS를 시도하며 + 단일 `AttemptTerminalCoordinator`가 winner와 cleanup owner를 각각 하나만 만든다. Success, 401, + retry-control, redirect, reconciliation, partial response와 cancellation 모두 공통 cleanup과 exhaustive + disposition resolver를 지나며 CAS loser가 정상 결과로 반환되지 않는다. +17. Wire/decoded/ratio/header/body limit과 truncation semantics가 있다. +18. DNS resolve-validate-connect, NAT64/transition address, SSRF/CIDR, redirect와 named proxy policy가 + 증명된다. +19. TLS hostname/trust, certificate-directed revocation egress와 OCSP/CRL signature/identity/ + freshness semantics, normal rotation와 emergency revoke/no-fallback/NOT_READY가 증명된다. +20. mTLS/OAuth/API-key credential lifecycle, exact OAuth token-client auth child tuple와 + cache/single-flight isolation key, shared owner/close가 증명된다. +21. OTel이 propagation을 단독 소유하고 duplicate CLIENT span이 없으며 sanitized attribute만 생성해 + raw URL/query/header가 SDK/processor/exporter에 한 번도 들어가지 않는다. +22. Metric/log에는 raw URL이 없고 metric generation/pool tag는 checked-in bounded role만 + 사용한다. 모든 telemetry에 secret/body/tenant/idempotency key가 없고 peer address는 trust-zone + policy를 따른다. Untrusted fetch는 attacker-controlled authority를 표준 HTTP telemetry에 넣지 않는다. +23. Liveness는 remote dependency와 분리되고 readiness impact/probe가 명시된다. +24. Ingress drain 뒤 outbound close/cancel/resource close와 normal/emergency generation lifecycle이 + 증명된다. +25. ACTIVE binding에서 derived selected card와 exact parent/child compatibility profile이 모두 + `release-eligible`이고, required/claimed evidence scenario set이 완전 일치하며 executed set이 이를 + 포함하고 evidence matrix가 0 skip으로 통과한다. `QUALIFICATION_ONLY` 결과는 ACTIVE_READY나 + release assertion으로 사용할 수 없다. DISABLED는 zero-resource를 증명하며 R2로 표시하지 않는다. +26. Architecture/env/config/public-path/dependency/supply-chain gate가 통과한다. +27. Container/test dependency가 digest/lock/checksum으로 고정된다. +28. Required runbook와 dashboard/alert/SLO link가 존재한다. +29. Current provider/version/card/parent-child compatibility profile/policy revision이 sanitized + descriptor에 나온다. +30. ACTIVE profile에는 actual feature consumer가 최소 하나 contract를 통과한다. DISABLED profile은 + consumer를 명시적 N/A로 검증한다. +31. Full selected release workflow가 fresh runner에서 root readiness task로 재실행된다. +32. Known gaps는 `not-implemented` card/profile로 fail-closed되고 R2 범위에 포함되지 않는다. Card + registry와 compatibility registry가 각각 card/profile maturity의 유일한 SSOT이며 selection은 + binding/catalog/provider의 exact tuple과 child dependency fingerprints에서만 파생된다. +33. LLM Wiki capture와 independent review evidence가 완료된다. + +현재 구현은 위 조건을 충족하지 않는다. 이 문서 완료는 구현 R2 완료가 아니다. + +## 41. Required runbooks + +구현과 함께 최소 다음 runbook을 제공한다. + +1. destination onboarding/offboarding; +2. operation catalog와 compatibility 변경; +3. timeout/deadline budget 조정; +4. pool saturation/pending acquire; +5. retry storm/retry budget exhaustion; +6. circuit open/half-open; +7. DNS failure/rebinding/TTL/address rotation; +8. TLS handshake/certificate expiry/trust rotation; +9. mTLS client certificate rotation; +10. OAuth/API key/secret rotation; +11. proxy outage/auth/direct-fallback verification; +12. streaming leak/oversize/decompression bomb; +13. `INDETERMINATE` mutation reconciliation; +14. readiness probe outage/false negative; +15. client generation stuck draining; +16. provider upgrade/rollback; +17. selected readiness card evidence failure; +18. security incident and credential/metadata leakage; +19. HTTP/2 GOAWAY/flow-control/downgrade; +20. untrusted URL fetch incident if that future card is ever selected; +21. combined retry/redirect/auth/reconciliation amplification ceiling exhaustion; +22. OCSP/CRL/AIA responder outage와 certificate-directed egress rejection; +23. credential/key/trust compromise emergency revoke, no-fallback와 NOT_READY recovery; +24. OAuth token/proxy/revocation child profile qualification and ownership leak. + +각 runbook: + +- symptom/alert; +- safe diagnostic query; +- secret-safe evidence; +- immediate containment; +- retry/restart 금지 조건; +- rollback; +- reconciliation/data impact; +- owner/escalation; +- recovery verification; + +을 포함한다. + +## 42. Primary references + +이 설계는 2026-07-27 기준으로 다음 primary source를 참고했다. + +- [Spring Framework REST Clients](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html): + `RestClient`, `exchange()`와 status-handler 경계. +- [Spring Framework `RestClient` Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestClient.html): + current API contract. +- [Spring Boot 4 REST Client](https://docs.spring.io/spring-boot/4.0/reference/io/rest-client.html): + preconfigured `RestClient.Builder`, HTTP Service, SSL integration. +- [Spring Boot `HttpClientSettings`](https://docs.spring.io/spring-boot/4.0/api/java/org/springframework/boot/http/client/HttpClientSettings.html): + Boot HTTP client settings surface. +- [Spring Boot `ClientHttpRequestFactoryBuilder`](https://docs.spring.io/spring-boot/4.0/api/java/org/springframework/boot/http/client/ClientHttpRequestFactoryBuilder.html): + request-factory selection/customization. +- [Java 21 `HttpClient`](https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpClient.html): + JDK client lifecycle, executor, redirect, protocol surface. +- [Java 21 `HttpRequest.Builder`](https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html): + request timeout/header/method surface. +- [Java networking properties](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/doc-files/net-properties.html): + JVM networking/DNS properties and scope. +- [Java 21 PKI Programmer's Guide](https://docs.oracle.com/en/java/javase/21/security/java-pki-programmers-guide.html): + certification path, CRLDP/AIA/OCSP network retrieval와 security properties. +- [Apache HttpComponents 5 pooling manager builder](https://hc.apache.org/httpcomponents-client-5.6.x/current/httpclient5/apidocs/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManagerBuilder.html): + pool construction and connection-manager options. +- [Apache HttpComponents 5 request configuration](https://hc.apache.org/httpcomponents-client-5.6.x/current/httpclient5/apidocs/org/apache/hc/client5/http/config/RequestConfig.Builder.html): + request/connect/connection-request policy surface. +- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html): + methods, status, representation, retry-related HTTP semantics. +- [RFC 9112 — HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9112.html): + framing and HTTP/1.1 message parsing. +- [RFC 9113 — HTTP/2](https://www.rfc-editor.org/rfc/rfc9113.html): + stream errors, `REFUSED_STREAM`, GOAWAY와 last-stream-ID semantics. +- [RFC 5280 — PKIX Certificate and CRL Profile](https://www.rfc-editor.org/rfc/rfc5280.html): + certificate AIA/CRL distribution point, CRL scope/signature/freshness semantics. +- [RFC 6960 — OCSP](https://www.rfc-editor.org/rfc/rfc6960.html): + authorized responder, CertID, response signature/status/time와 replay semantics. +- [RFC 6749 — OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749.html): + client authentication, token endpoint와 scope semantics. +- [RFC 6052 — IPv6 Addressing of IPv4/IPv6 Translators](https://www.rfc-editor.org/rfc/rfc6052.html): + NAT64 IPv4-embedded IPv6 prefix/translation format. +- [RFC 6890 — Special-Purpose Address Registries](https://www.rfc-editor.org/rfc/rfc6890.html): + IPv4/IPv6 special-purpose address classification context. +- [RFC 6585 — Additional HTTP Status Codes](https://www.rfc-editor.org/rfc/rfc6585.html): + 429 and `Retry-After` context. +- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html): + application/network-layer SSRF controls. +- [OpenTelemetry HTTP spans](https://opentelemetry.io/docs/specs/semconv/http/http-spans/): + client span and resend semantic convention. +- [OpenTelemetry HTTP metrics](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/): + HTTP client metric semantic convention. +- [OpenTelemetry metrics concepts](https://opentelemetry.io/docs/concepts/signals/metrics/): + attribute-set aggregation과 cardinality context. +- [W3C Trace Context](https://www.w3.org/TR/trace-context/): + `traceparent`/`tracestate` format and propagation. +- [Spring Boot SSL](https://docs.spring.io/spring-boot/reference/features/ssl.html): + SSL bundle and documented reload integration boundary. +- [Spring Security OAuth2 Client](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html): + client registration/provider/authorized-client model. +- [Resilience4j Retry](https://resilience4j.readme.io/docs/retry): + retry configuration and result/exception predicates. +- [Resilience4j CircuitBreaker](https://resilience4j.readme.io/docs/circuitbreaker): + circuit breaker state/configuration model. +- [Idempotency-Key Internet-Draft 07](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/07/): + HTTP idempotency-key 설계 참고. 2026-04-18에 만료된 Internet-Draft이며 표준으로 간주하지 + 않는다. 실제 partner contract가 우선한다. + +Primary source가 지원하는 API가 실제 Spring Boot BOM/selected provider version에서 동일한지는 +구현 시 dependency lock과 compatibility test로 다시 확인한다. 문서 링크는 executable evidence를 +대신하지 않는다. diff --git a/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md b/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md new file mode 100644 index 0000000..ce8e386 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md @@ -0,0 +1,415 @@ +# Fileserver R2 Control Plane and Provider Selection Design + +- Date: 2026-07-28 +- Status: 승인된 설계, 구현 전 +- Scope: provider-neutral R2 control plane, explicit destination/provider selection, first + `local-persistent` qualification provider +- Parent: + [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md) + +## 1. 목표 + +현재 `LocalFilePublicationAdapter`의 single-node process-restart R1을 운영 topology의 기본값으로 +승격하지 않는다. 이번 increment는 다음을 구현한다. + +1. application에는 기존 provider-neutral `FilePublicationPort`만 유지한다. +2. adapter 내부에 destination binding, provider descriptor, durable operation/manifest/reference + control plane을 둔다. +3. 활성화된 Fileserver는 정확한 destination과 provider를 명시해야 하며 implicit local fallback을 + 금지한다. +4. 첫 qualification provider로 pre-provisioned persistent filesystem을 사용하는 + `local-persistent`를 구현한다. +5. `shared-mounted`와 `sftp`가 같은 control-plane state machine을 재사용할 수 있게 하되 이번 + increment에서 가짜 provider나 동작하지 않는 bean을 만들지 않는다. + +`local-persistent`는 container writable layer나 임시 디렉터리를 의미하지 않는다. 단일 노드 또는 +node-attached persistent volume과 private owner boundary가 증명된 환경만 대상으로 한다. + +## 2. 비범위 + +이번 increment에 포함하지 않는다. + +- NFS 또는 다른 shared mount의 multi-client correctness; +- SFTP SDK, connection pool, credential, OpenSSH qualification; +- cross-node producer fencing; +- background reaper, retention delete, quota reservation; +- metrics/tracing/health implementation; +- optional content read/delete/list API; +- object storage. Object storage는 별도 outbound leaf의 책임이다. + +이 항목은 seam만 만들지 않는다. 실제 semantic provider를 구현하는 후속 increment에서만 +dependency, bean, setting을 추가한다. + +## 3. 검토한 접근 + +### A. 현재 local adapter를 바로 R2로 표시 + +설정과 change surface는 작지만 provider selector, terminal manifest, opaque-reference direct +lookup과 strict startup evidence가 없다. R2를 과장하므로 선택하지 않는다. + +### B. Local, NFS, SFTP를 동시에 구현 + +최종 기능은 많지만 서로 다른 보장과 real-service CI가 한 change surface에 결합된다. NFS와 +OpenSSH 인프라가 없으면 검증되지 않은 provider가 남으므로 선택하지 않는다. + +### C. Provider-neutral control plane + local-persistent 첫 qualification + +공통 state machine과 binding을 먼저 고정하고 한 provider를 실제 crash/security 테스트로 +qualification한다. 이후 provider가 control-plane 계약을 재사용하면서도 각자의 보장을 별도로 +증명할 수 있다. 이 접근을 선택한다. + +## 4. 계층과 모듈 경계 + +```text +application-core + FilePublicationPort + FilePublishRequest + FilePublishReceipt + | + v +adapter:outbound:fileserver + RoutingFilePublicationAdapter + | + +-- DestinationBindingRegistry + +-- FilePublicationProviderRegistry + +-- DurablePublicationCoordinator + +-- ProviderControlPlane + | + +-- LocalPersistentPublicationProvider +``` + +- application/domain에는 provider ID, filesystem path, manifest locator, Spring 또는 NIO 타입을 + 추가하지 않는다. +- `RoutingFilePublicationAdapter`만 production `FilePublicationPort` bean이다. +- provider와 control-plane SPI는 fileserver package 내부 타입이다. 범용 filesystem/SDK API를 + public bean으로 노출하지 않는다. +- `shared-mounted`와 `sftp` 타입 값은 구현 전까지 accepted setting으로 등록하지 않는다. + +## 5. Application 계약 변경 + +기존 request와 opaque reference를 유지한다. R2 provider가 달성한 보장을 정확히 보고할 수 있도록 +`FilePublishReceipt.DurabilityGuarantee`에 다음 값만 추가한다. + +```text +FILE_AND_DIRECTORY_SYNC +``` + +이 값은 startup probe와 process-crash qualification을 모두 통과한 provider만 반환한다. +호출한 sync가 물리 device, volume replica 또는 storage-controller power-loss protection까지 +완료됐다는 뜻은 아니다. 그 축은 deployment/storage evidence로 별도 판정한다. +`PROCESS_LOCAL_SYNC` 또는 `PROVIDER_ACK_ONLY`를 요구 보장보다 약한 상태에서 자동으로 R2 값으로 +올리지 않는다. + +새 opaque reference 형식은 다음 의미를 가지되 application은 내부 segment를 해석하지 않는다. + +```text +fsr1... +``` + +- `route-token`: startup에서 생성된 bounded destination route allowlist 값; +- `file-id`: CSPRNG 128-bit 이상; +- `check-digits`: accidental truncation/corruption 검출; +- provider locator, operation ID, tenant/user ID, host/path는 포함하지 않는다. + +Reference는 authorization token이 아니다. authorization은 application use case의 책임이다. + +## 6. 명시적 설정과 선택 + +새 canonical prefix는 `app.fileserver`다. + +```yaml +app: + fileserver: + enabled: false + destinations: + local-export: + provider-ref: local-primary + required-publication: unique-atomic-create + required-durability: file-and-directory-sync + maximum-rows: 1000000 + maximum-encoded-bytes: 1073741824 + providers: + local-primary: + type: local-persistent + root-directory: ${APP_FILESERVER_LOCAL_ROOT:} + auto-create: false + strict-path-security: true + expected-file-store-name: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME:} + expected-file-store-type: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE:} + mount-sentinel-name: .ca-fileserver-volume + mount-sentinel-sha256: ${APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256:} + expected-owner: ${APP_FILESERVER_LOCAL_EXPECTED_OWNER:} + maximum-root-mode: "0750" +``` + +규칙: + +- `enabled=true`이면 destination과 provider가 각각 하나 이상 필요하다. +- 모든 destination은 존재하는 provider 하나를 참조한다. +- request destination에 binding이 없으면 producer 호출 전에 실패한다. +- provider type의 기본값은 없다. +- `local-persistent` root는 absolute, existing, pre-provisioned directory여야 한다. +- `auto-create=true`는 `local-persistent`에서 거부한다. +- root와 mount sentinel은 operator가 미리 만든다. Root attestation이 끝난 뒤 adapter가 private + top-level control/data directory와 bounded hash shard를 restrictive POSIX creation mode로 + 생성할 수 있으며, 생성할 때마다 parent identity와 directory sync를 확인한다. +- container ephemeral 경로를 위한 `local-dev`는 별도 후속 profile이다. production 설정과 + 같은 guarantee를 공유하지 않는다. +- 기존 `ca-skeleton.fileserver.*`는 R1/legacy compatibility selector로만 남는다. 새 R2 설정과 + 동시에 활성화되면 startup을 실패시킨다. 암묵 migration이나 precedence를 두지 않는다. + +## 7. Startup capability compilation + +application traffic을 받기 전에 destination별 effective descriptor를 한 번 compile한다. + +`local-persistent`는 다음을 모두 검증한다. + +1. root와 모든 ancestor가 symbolic link가 아니다. +2. root real path가 설정 absolute path와 일치한다. +3. configured owner와 실제 owner가 일치한다. +4. POSIX permission이 configured maximum보다 넓지 않고 group/world writable이 아니다. +5. `FileStore.name()`과 `type()`이 설정 값과 일치한다. +6. mount sentinel이 regular no-follow file이고 configured SHA-256와 일치한다. +7. data, staging, operations, manifests, references, quarantine directory가 같은 + `FileStore`에 있다. +8. control directory는 private owner boundary이며 symlink가 아니다. +9. `SecureDirectoryStream`을 열 수 있다. +10. exclusive create, file force, hard-link create, directory force가 private probe directory에서 + 성공한다. + +Probe artifact는 unique name만 사용하며 successful cleanup과 parent directory force까지 +완료해야 한다. Probe 실패는 capability downgrade가 아니라 startup failure다. + +JDK가 directory-relative hard-link primitive를 제공하지 않으므로 hard-link publish는 다음 +boundary에서만 허용한다. + +- root/control/data directories가 adapter owner 전용이고 untrusted writer가 없음; +- publish 직전과 직후 root identity, directory file key, mount sentinel을 다시 확인; +- target은 CSPRNG unique name; +- pre/post identity가 바뀌면 성공을 반환하지 않고 `PUBLISH_INDETERMINATE`; +- privileged host administrator 또는 same-owner malicious process와의 경쟁은 guarantee 범위가 + 아니며 deployment isolation requirement로 기록한다. + +untrusted writer가 같은 root에 entry를 만들 수 있는 환경은 strict local R2가 아니다. + +## 8. Durable control plane + +```text +.ca-fileserver/ + operations//.json + manifests//.json + references//.json + staging//.part + quarantine/ + probe/ +data// +``` + +모든 locator는 validated single segment 또는 adapter가 생성한 bounded relative segment다. +Caller path를 받지 않는다. + +### 8.1 Operation journal v2 + +필수 필드: + +```text +schemaVersion +stateRevision +state +operationId +requestFingerprint +effectivePolicyRevision +effectivePolicyDigest +destinationId +providerId +fileId +routeToken +publishedFileName +stageFileName +byteSize +rowCount +columnCount +sha256 +formulaMitigatedCount +manifestDigest +referenceDigest +createdAt +sealedAt +publishedAt +lastFailureCode +receiptSnapshot +``` + +State는 `WRITING`, `SEALED`, `DATA_PUBLISHED`, `MANIFEST_PUBLISHED`, +`REFERENCE_PUBLISHED`, `PUBLISHED`, `QUARANTINED`다. + +### 8.2 Private manifest v1 + +Manifest는 operation/file/provider/reference/fingerprint, schema·format·policy digest, byte/count, +SHA-256, achieved guarantees, internal relative locator를 기록한다. Absolute path, raw row/cell, +credential, raw tenant/user ID는 저장하지 않는다. + +### 8.3 Reference index v1 + +Reference index는 opaque `file-id`에서 operation ID, file version, manifest digest와 internal +relative locator로 direct lookup한다. Directory scan은 receipt restoration의 authority가 아니다. + +### 8.4 Record update + +각 control record는: + +1. sibling private temp file을 `CREATE_NEW`; +2. bounded canonical JSON encoding; +3. file `force(true)`; +4. same-directory atomic replace; +5. parent directory force; +6. read-back schema/revision/digest verification; + +순서로 갱신한다. 낮은 revision, fingerprint mismatch, newer schema는 자동 덮어쓰지 않는다. + +## 9. Publication ordering + +```text +J-WRITING + -> stage stream/force +J-SEALED + -> exclusive hard-link data publish + -> data directory force +J-DATA_PUBLISHED + -> private manifest publish/force +J-MANIFEST_PUBLISHED + -> reference index publish/force +J-REFERENCE_PUBLISHED + -> terminal journal + receipt snapshot publish/force +J-PUBLISHED + -> receipt return +``` + +- Producer는 accepted attempt에서 최대 한 번 호출한다. +- `SEALED` 이후 retry/recovery는 staged bytes만 사용한다. +- terminal journal force 전에는 receipt를 반환하지 않는다. +- target collision, digest mismatch 또는 root identity change는 자동 overwrite하지 않는다. +- final data가 있어도 manifest/reference가 없으면 아직 terminal success가 아니다. + +## 10. Deterministic recovery + +Recovery는 operation ID direct lookup으로 실행하며 startup full scan에 의존하지 않는다. + +| 확인된 상태 | 조치 | +| --- | --- | +| terminal journal + matching manifest/reference/data | 저장된 receipt 복원 | +| SEALED + valid stage, data 없음 | data publication부터 재개 | +| SEALED + matching data | manifest publication부터 재개 | +| DATA_PUBLISHED + matching data | manifest publication 재개 | +| MANIFEST_PUBLISHED + matching manifest/data | reference publication 재개 | +| REFERENCE_PUBLISHED + all matching | terminal journal 완성 | +| data digest mismatch | `QUARANTINED`, integrity failure | +| marker/manifest/reference schema newer | 보존 후 fail-fast/quarantine | +| fingerprint conflict | typed conflict, 기존 artifact 보존 | +| root/mount identity change | indeterminate, write/recovery 중단 | + +Truth priority: + +```text +matching data + private manifest + reference + > terminal operation record + > non-terminal operation record + > in-memory state +``` + +모순이 있으면 임의 성공이나 삭제 대신 quarantine evidence를 기록한다. + +## 11. Compatibility + +- R1 journal schema v1은 읽을 수 있어야 한다. +- R1 terminal receipt는 기존 `PROCESS_LOCAL_SYNC` 보장 그대로 복원한다. +- R1 artifact를 자동으로 R2 manifest/reference로 승격하지 않는다. +- R2 writer는 journal v2만 생성한다. +- 기존 overwrite-capable legacy port는 별도 root와 opt-in을 유지하며 R2 control plane에 접근하지 + 않는다. +- R1과 R2 selector가 동시에 활성화되면 ambiguous composition으로 startup을 실패시킨다. + +## 12. Failure semantics + +- 설정/보장 mismatch: startup failure; +- destination 없음: producer 전 deterministic request failure; +- stage 이전 capacity/validation failure: not applied; +- stage/write failure: failed, partial stage는 recovery evidence가 아니면 정리; +- sealed 이후 filesystem timeout/IO/root identity change: indeterminate; +- published data와 metadata 불일치: integrity/quarantine; +- journal/control record corruption: provider exception을 노출하지 않고 typed indeterminate; +- guarantee를 낮춰 성공시키는 fallback은 없다. + +## 13. 테스트와 증거 + +### 13.1 Unit/contract + +- exact destination/provider selection과 no-default; +- R1/R2 simultaneous activation rejection; +- reference grammar/check digits/forged route rejection; +- journal v2, manifest, reference canonical round-trip; +- state revision과 fingerprint conflict; +- achieved durability value invariants. + +### 13.2 Local integration + +- pre-provisioned root requirement; +- owner/mode/FileStore/sentinel mismatch startup failure; +- symlink ancestor/control/data rejection; +- staging/final/control same `FileStore`; +- successful capability probe와 cleanup; +- partial final visibility 0건; +- same operation concurrency와 producer once; +- target collision no overwrite; +- data/manifest/reference digest mismatch quarantine. + +### 13.3 Crash qualification + +Forked JVM helper를 사용해 다음 force boundary 직후 process를 강제 종료하고 새 JVM에서 같은 +operation을 재시도한다. + +```text +J-WRITING +stage force +J-SEALED +data link +data directory force +manifest force +manifest directory force +reference force +reference directory force +terminal journal force +terminal journal directory force +``` + +각 boundary에서 결과는 다음 중 하나여야 한다. + +- producer 재실행 없이 동일 receipt 복원; +- verified sealed bytes로 publication 완성; +- typed indeterminate/quarantine. + +partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용하지 않는다. + +### 13.4 플랫폼 + +- Linux/POSIX + `SecureDirectoryStream` + directory force qualification lane에서만 + `FILE_AND_DIRECTORY_SYNC`을 검증한다. +- capability가 없는 일반 unit-test filesystem에서는 R1 보장만 테스트하며 R2 service test를 + skip 성공으로 처리하지 않는다. + +## 14. 완료 기준 + +이번 increment의 완료는 “Fileserver 전체가 모든 운영환경에서 R2”라는 뜻이 아니다. + +완료를 주장하려면: + +1. provider 기본값 없이 exact binding이 동작한다. +2. `local-persistent` startup probe가 모든 required capability를 증명한다. +3. terminal manifest/reference direct lookup이 구현된다. +4. 모든 publication force boundary의 crash test가 deterministic result를 낸다. +5. strict path/mount identity/security tests가 통과한다. +6. public path와 clean architecture gate가 통과한다. +7. R1 compatibility artifact를 R2로 자동 승격하지 않는다. +8. 문서와 receipt는 `local-persistent` qualification만 R2라고 표시한다. + +후속 순서는 Phase 3 maintenance/resource limits, Phase 4 SFTP, Phase 5 shared-mounted/NFS evidence다. diff --git a/src/.env b/src/.env index b58d8bb..a85dc63 100644 --- a/src/.env +++ b/src/.env @@ -23,6 +23,19 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200 # ----- Optional integration adapters (default: all disabled) ----- APP_CACHE_REDIS_ENABLED=false +APP_CACHE_REDIS_CLIENT_MODE=managed +APP_CACHE_REDIS_HOST=localhost +APP_CACHE_REDIS_PORT=6379 +APP_CACHE_REDIS_PASSWORD= +APP_CACHE_REDIS_KEY_HMAC_SECRET= +APP_CACHE_REDIS_COMMAND_TIMEOUT=2s +APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8 +APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216 +APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local +APP_CACHE_REDIS_SEMANTIC_REGION=default +APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576 +APP_CACHE_DEFAULT_TTL=300s +APP_CACHE_NEGATIVE_TTL=60s APP_MESSAGING_BROKER= APP_MESSAGING_KAFKA_BROKERS= APP_NOTIFICATION_SLACK_PROVIDER= @@ -32,6 +45,7 @@ APP_NOTIFICATION_EMAIL_PROVIDER= APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s APP_OUTBOUND_HTTP_READ_TIMEOUT=5s APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s +APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS=128 APP_OUTBOUND_HTTP_RETRY_ENABLED=false APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3 APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms diff --git a/src/.gitignore b/src/.gitignore index 385efc5..1c7e36f 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -10,5 +10,8 @@ build/ .classpath .settings/ +# jqwik property-test runtime state +.jqwik-database + # OS .DS_Store diff --git a/src/README.md b/src/README.md index 2e0d445..ec2b0b3 100644 --- a/src/README.md +++ b/src/README.md @@ -77,12 +77,12 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 ### `verifyCleanArchitectureDependencies` -- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle - 프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 - 실패시킵니다. -- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit - 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는 - 보호 기능을 못 합니다. +- **하는 일.** [config/architecture/modules.json](config/architecture/modules.json)의 + `allowed_dependencies`를 읽고, 실제 Gradle 프로젝트 의존(`api` / `implementation` / + `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 실패시킵니다. +- **JSON registry가 의존 방향의 SSOT 입니다.** 새 모듈이나 새 production 의존 edge를 추가하면 + registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는 + 같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다. ### `verifyOneTypePerFile` (code-conventions I6) @@ -121,15 +121,17 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다. - **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로 snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후 - 반영합니다. + 재생성된 snapshot 을 함께 커밋합니다. - **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시 `SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection 은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가 `RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()` 을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로 변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다. -- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh - checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다. +- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안 + baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh + checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한 뒤 + 보안 리뷰와 함께 커밋합니다. ### `verifyTrivyignore` @@ -226,6 +228,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 fail-fast sentinel 이 포트를 충족합니다(Layer 3). - **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`. +- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가 + 제공한 `RedisClient` bean을 사용합니다. - **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시 fail-fast). - **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수, @@ -243,6 +247,8 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3). - **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero. - **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예: `10s`), 필수, non-zero. +- **`APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS`** — client별 살아 있는 logical-call worker 상한. + 기본값 `128`, 허용 범위 `1..10000`. - **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이 있어야 하며(D3 가드), 없으면 기동 실패. - retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존): diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md index ee11bfd..b06028e 100644 --- a/src/adapter/inbound/graphql/CLAUDE.md +++ b/src/adapter/inbound/graphql/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-inbound-graphql` - Gradle path: `:adapter:inbound:graphql` -- Focused test: `./gradlew :adapter:inbound:graphql:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:graphql:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.inbound.graphql`. diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle index 6c3aaf7..ddb5a7f 100644 --- a/src/adapter/inbound/graphql/build.gradle +++ b/src/adapter/inbound/graphql/build.gradle @@ -11,15 +11,10 @@ description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)' dependencies { - implementation project(':application-core') - implementation project(':domain-core') implementation project(':shared-contract') implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' - - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' // GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema + // controller through a real AnnotatedControllerConfigurer and drives it with an diff --git a/src/adapter/inbound/graphql/gradle.lockfile b/src/adapter/inbound/graphql/gradle.lockfile index 89eb9bf..e930d86 100644 --- a/src/adapter/inbound/graphql/gradle.lockfile +++ b/src/adapter/inbound/graphql/gradle.lockfile @@ -5,10 +5,6 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs @@ -121,7 +117,6 @@ org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClass org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/inbound/grpc/CLAUDE.md b/src/adapter/inbound/grpc/CLAUDE.md index 419404e..f4e39ad 100644 --- a/src/adapter/inbound/grpc/CLAUDE.md +++ b/src/adapter/inbound/grpc/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-inbound-grpc` - Gradle path: `:adapter:inbound:grpc` -- Focused test: `./gradlew :adapter:inbound:grpc:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:grpc:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.inbound.grpc`. diff --git a/src/adapter/inbound/grpc/build.gradle b/src/adapter/inbound/grpc/build.gradle index dedfafd..cbde086 100644 --- a/src/adapter/inbound/grpc/build.gradle +++ b/src/adapter/inbound/grpc/build.gradle @@ -17,20 +17,16 @@ dependencyManagement { } dependencies { - implementation project(':application-core') - implementation project(':domain-core') implementation project(':shared-contract') implementation 'org.springframework.boot:spring-boot-starter' implementation 'io.grpc:grpc-netty-shaded' - implementation 'io.grpc:grpc-protobuf' - implementation 'io.grpc:grpc-stub' implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection) - // grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity - // with the feature module (the skeleton itself generates no stubs). - compileOnly 'org.apache.tomcat:annotations-api:6.0.53' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // The boot test directly builds generated health/reflection protobuf messages. grpc-services + // does not expose protobuf-java on its compile API, so keep the narrower test-only declaration. + testImplementation 'io.grpc:grpc-protobuf' } diff --git a/src/adapter/inbound/grpc/gradle.lockfile b/src/adapter/inbound/grpc/gradle.lockfile index e5a8107..21d1582 100644 --- a/src/adapter/inbound/grpc/gradle.lockfile +++ b/src/adapter/inbound/grpc/gradle.lockfile @@ -12,7 +12,7 @@ com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClass com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath -com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.api.grpc:proto-google-common-protos:2.41.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor @@ -37,7 +37,7 @@ com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath -com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle @@ -54,7 +54,7 @@ io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath -io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath @@ -88,7 +88,6 @@ org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat:annotations-api:6.0.53=compileClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/inbound/web/CLAUDE.md b/src/adapter/inbound/web/CLAUDE.md index 0757a87..90e5781 100644 --- a/src/adapter/inbound/web/CLAUDE.md +++ b/src/adapter/inbound/web/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-inbound-web` - Gradle path: `:adapter:inbound:web` -- Focused test: `./gradlew :adapter:inbound:web:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:web:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.inbound.web`. @@ -18,6 +18,9 @@ Package root: `dev.caskeleton.adapter.inbound.web`. - Request/response DTOs. - Request DTO to application command mapping. - Authentication, validation, error mapping, filters, and web/security settings. +- Sanitized request correlation context exposed through application-owned `CorrelationIdPort`. +- Transport-owned OpenAPI customization that keeps `ApiError.details` as `type: object` without + leaking Swagger dependencies into `shared-contract`. ## Allowed diff --git a/src/adapter/inbound/web/README.md b/src/adapter/inbound/web/README.md index b229297..d597be3 100644 --- a/src/adapter/inbound/web/README.md +++ b/src/adapter/inbound/web/README.md @@ -10,6 +10,16 @@ --- +## OpenAPI contract stabilization + +Springdoc 3 represents an untyped Java `Object` as an unconstrained OAS 3.1 schema (`{}`). +`OpenApiContractConfig` owns the transport-specific correction for the shared `ApiError.details` +field and publishes it as `type: object`. This preserves the committed HTTP contract without adding +Swagger annotations or dependencies to `shared-contract`. Real-server OpenAPI tests import this +production configuration and compare the result with the committed snapshot. + +--- + ## auth — 인증 (OIDC resource server) ### SecurityConfig @@ -323,6 +333,12 @@ 같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며, 변환 단일 지점은 `ResponseMetaFactory`. +### MdcCorrelationIdPortAdapter +- `RequestLoggingFilter`가 무해화하고 MDC `correlation_id`에 넣은 값을 application-core의 + `CorrelationIdPort`로 투영한다. +- absent/blank는 `Optional.empty()`로 반환한다. application/sample 계층은 SLF4J/MDC를 직접 + 참조하지 않고 event-id fallback 정책만 소유한다. + ### HeaderSanitizer - 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을 가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를 diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 5053694..2380c62 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -1,16 +1,18 @@ -// HTTP / web adapters. Depends on application, domain, and shared operational contracts. +// HTTP / web adapters. Depends on application and shared operational contracts. dependencies { - implementation project(':domain-core') implementation project(':application-core') implementation project(':shared-contract') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.openapitools:jackson-databind-nullable:0.2.6' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + implementation('org.openapitools:jackson-databind-nullable:0.2.6') { + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind' + } // feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the // running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated — // never a hand-maintained stale schema). The release-blocking drift gate is // owned by feature-contract-verification-test-suite (planned). - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0' } diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index 77513d8..e156e5b 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -50,9 +50,9 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -125,9 +125,10 @@ org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/OpenApiContractConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/OpenApiContractConfig.java new file mode 100644 index 0000000..9777ef9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/OpenApiContractConfig.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.web.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.Map; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Keeps transport-owned OpenAPI schema details stable across springdoc library upgrades. + * + *

{@code ApiError.details} is represented by {@code Object} in the shared response contract. + * Springdoc 3 renders an untyped Java {@code Object} as an unconstrained OAS 3.1 schema. The public + * HTTP contract remains object-shaped, so the web adapter restores that transport-specific type + * without adding Swagger dependencies or annotations to {@code shared-contract}. + */ +@Configuration(proxyBeanMethods = false) +public class OpenApiContractConfig { + + @Bean + OpenApiCustomizer apiErrorDetailsObjectSchemaCustomizer() { + return openApi -> { + Components components = openApi.getComponents(); + Map schemas = components == null ? null : components.getSchemas(); + Schema apiError = schemas == null ? null : schemas.get("ApiError"); + Map properties = apiError == null ? null : apiError.getProperties(); + if (properties != null && properties.containsKey("details")) { + properties.put("details", new ObjectSchema()); + } + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/MdcCorrelationIdPortAdapter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/MdcCorrelationIdPortAdapter.java new file mode 100644 index 0000000..ef9f1d3 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/MdcCorrelationIdPortAdapter.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import dev.caskeleton.application.observability.CorrelationIdPort; +import java.util.Optional; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +/** Reads the current request's sanitized correlation identifier from the inbound web MDC. */ +@Component +public class MdcCorrelationIdPortAdapter implements CorrelationIdPort { + + @Override + public Optional currentCorrelationId() { + return Optional.ofNullable(MDC.get(MdcKeys.CORRELATION_ID)).filter(value -> !value.isBlank()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/config/JacksonNullableConfigTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/config/JacksonNullableConfigTest.java new file mode 100644 index 0000000..a889295 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/config/JacksonNullableConfigTest.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.openapitools.jackson.nullable.JsonNullable; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +class JacksonNullableConfigTest { + + private final ObjectMapper mapper = + JsonMapper.builder().addModule(new JacksonNullableConfig().jsonNullableModule()).build(); + + @Test + void readsPresentValue() throws Exception { + Payload payload = mapper.readValue("{\"value\":\"configured\"}", Payload.class); + + assertThat(payload.value().isPresent()).isTrue(); + assertThat(payload.value().get()).isEqualTo("configured"); + } + + @Test + void readsExplicitNullAsPresentNull() throws Exception { + Payload payload = mapper.readValue("{\"value\":null}", Payload.class); + + assertThat(payload.value().isPresent()).isTrue(); + assertThat(payload.value().get()).isNull(); + } + + @Test + void readsMissingPropertyAsUndefined() throws Exception { + Payload payload = mapper.readValue("{}", Payload.class); + + assertThat(payload.value()).isNotNull(); + assertThat(payload.value().isPresent()).isFalse(); + } + + private record Payload(JsonNullable value) {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/MdcCorrelationIdPortAdapterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/MdcCorrelationIdPortAdapterTest.java new file mode 100644 index 0000000..bdfb21f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/MdcCorrelationIdPortAdapterTest.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +class MdcCorrelationIdPortAdapterTest { + + private final MdcCorrelationIdPortAdapter adapter = new MdcCorrelationIdPortAdapter(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void readsSanitizedRequestCorrelationId() { + MDC.put(MdcKeys.CORRELATION_ID, "corr-123"); + + assertThat(adapter.currentCorrelationId()).contains("corr-123"); + } + + @Test + void treatsBlankMdcValueAsAbsent() { + MDC.put(MdcKeys.CORRELATION_ID, " "); + + assertThat(adapter.currentCorrelationId()).isEmpty(); + } + + @Test + void reportsAbsenceWhenRequestContextIsMissing() { + assertThat(adapter.currentCorrelationId()).isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/CLAUDE.md b/src/adapter/inbound/websocket/CLAUDE.md index db2db38..e7c0def 100644 --- a/src/adapter/inbound/websocket/CLAUDE.md +++ b/src/adapter/inbound/websocket/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-inbound-websocket` - Gradle path: `:adapter:inbound:websocket` -- Focused test: `./gradlew :adapter:inbound:websocket:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:websocket:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.inbound.websocket`. diff --git a/src/adapter/inbound/websocket/build.gradle b/src/adapter/inbound/websocket/build.gradle index 4622b51..8c16496 100644 --- a/src/adapter/inbound/websocket/build.gradle +++ b/src/adapter/inbound/websocket/build.gradle @@ -11,13 +11,9 @@ description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)' dependencies { - implementation project(':application-core') implementation project(':domain-core') - implementation project(':shared-contract') implementation 'org.springframework.boot:spring-boot-starter-websocket' - implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } diff --git a/src/adapter/inbound/websocket/gradle.lockfile b/src/adapter/inbound/websocket/gradle.lockfile index 1089ce2..4a5625f 100644 --- a/src/adapter/inbound/websocket/gradle.lockfile +++ b/src/adapter/inbound/websocket/gradle.lockfile @@ -5,10 +5,6 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs diff --git a/src/adapter/outbound/cache-redis/CLAUDE.md b/src/adapter/outbound/cache-redis/CLAUDE.md index 95ab2b6..1e341b8 100644 --- a/src/adapter/outbound/cache-redis/CLAUDE.md +++ b/src/adapter/outbound/cache-redis/CLAUDE.md @@ -4,25 +4,37 @@ - Module ID: `adapter-outbound-cache-redis` - Gradle path: `:adapter:outbound:cache-redis` -- Focused test: `./gradlew :adapter:outbound:cache-redis:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:cache-redis:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.cache`. ## Responsibility -- Implement cache stores, routing, Redis capability, and fail-open technical behavior behind ports. -- Own cache binding settings and Redis client adaptation. +- Implement semantic cache ports from `application-core` without exposing Redis concepts to core. +- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic + facades, runtime client adaptation, and capability-specific failure semantics. +- Keep the legacy cache router isolated while consumers migrate to semantic ports. - Reuse `adapter:outbound:support` for shared outbound concerns. ## Boundaries -- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- Allowed dependency edges come only from the module's + `src/config/architecture/modules.json` entry. - No inbound transport, persistence entity/repository, bootstrap, or sample dependency. - Cache adapters do not decide business freshness, entitlement, or domain fallback rules. +- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK + objects, topology, or connection types. +- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or + fencing. +- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL, + persistence/restart, eviction and fault evidence are required separately for R2. ## Tests -Use fake Redis clients and contract tests for routing/fail-open behavior. Do not use a real network in -focused tests; configuration changes include binding/validation coverage. +Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion +requires a separate real Redis service lane; it may never be silently skipped when selected. + +`redisServiceTest` is the explicit standalone lane. It fails when its host/port properties are +missing; the default unit task excludes its `redis-service` tag. diff --git a/src/adapter/outbound/cache-redis/README.md b/src/adapter/outbound/cache-redis/README.md index 8daccd0..b0fd2c3 100644 --- a/src/adapter/outbound/cache-redis/README.md +++ b/src/adapter/outbound/cache-redis/README.md @@ -1,37 +1,121 @@ # adapter:outbound:cache-redis — 설계 결정 참조 -캐시 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.cache`(`core` 서브 -패키지에 라우팅/SPI 추상화, `redis` 서브패키지에 Redis 바인딩). `:adapter:outbound:support` 에 -의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다. +캐시/Redis 기술 capability 아웃바운드 모듈. 패키지 루트: +`dev.caskeleton.adapter.outbound.cache`. `application-core`의 provider-neutral cache contract를 +구현할 수 있는 경계와 Redis physical key/atomic-program 기반을 소유한다. -허용/금지 의존 정책은 `src/build.gradle` 의 -`allowedProjectDependencies['adapter:outbound:cache-redis']` 항목이 SSOT 다(이 모듈은 아직 별도 -CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 -기록이다. +허용/금지 의존 정책은 `src/config/architecture/modules.json`의 +`adapter-outbound-cache-redis` 항목이 SSOT다. 상세 목표와 미구현 단계는 +`docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`에 있다. -## 모듈 개요 +## 현재 readiness -application-core 포트 뒤에 두는 **선택형** 캐시 어댑터다. `@ConditionalOnProperty` -(`APP_CACHE_REDIS_ENABLED`)로 게이팅되고 기본 비활성이다. `core` 서브패키지는 라우팅/SPI 추상화만 -갖고, `redis` 서브패키지가 이 모듈이 기본 제공하는 유일한 구체 백엔드(`RedisCacheAdapterConfig` -/ `RedisCacheStore`)다. 다른 벤더 백엔드가 필요하면 `CacheBackend` SPI 를 구현해 빈으로 추가한다. +현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle, +finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative +TTL, digest-protected bounded binary envelope, HMAC physical key, +invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다. +`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고 +managed connection을 생성하지 않는다. -## 중앙 fail-open 합성 +명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry와 +compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster, +TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다. -`FailOpenCacheStore` 데코레이터는 `CacheRouterConfig` 가 모든 `CacheBackend` 에 **중앙에서** -적용한다 — 백엔드 설정이 실수로 fail-open 정책을 빠뜨릴 수 없다. 백엔드 실패는 cache-miss 로 -다운그레이드돼 외부 장애가 5xx 로 번지지 않는다. 바인딩되지 않은 논리 이름은 설정 오류이며 -라우터에서 fail-fast 한다(Layer 3). +## Application cache contract -## 기여 계약은 `CacheBackend`, SPI 는 `CacheStore` +`application-core`의 `CacheRegionPort`는 다음을 분리한다. -기여(contribution) 타입을 `CacheStore` 가 아닌 `CacheBackend` 로 둔 건 의도적이다 — 임의의 -`CacheStore` 빈이 실수로 라우팅되지 않게 하고, 타입이 IDE 탐색 가능하며 중복 id 는 startup 을 -실패시킨다. `CacheStore.get()` 의 `Optional.empty()` 는 miss 를 뜻한다(SDK 타입이 어댑터 밖으로 -새지 않게 — B7). +- fresh/stale positive hit; +- authoritative negative hit; +- normal absent/expired/invalidated miss; +- incompatible schema; +- unavailable/overloaded와 operation certainty; +- recorded/conditional/degraded/indeterminate mutation; +- invalidated/already-absent/degraded/indeterminate invalidation. -## 라우팅 바인딩 +TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의 +use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다. -논리 캐시 이름 → 백엔드는 `app.cache.bindings.=` 로 선택하며, `backendId` 는 -`CacheBackend#backendId()` 에서 온다. 백엔드는 `@ConditionalOnProperty` 게이팅 config(예: -`RedisCacheAdapterConfig`)가 `CacheBackend` 빈으로 기여한다. +## Physical key + +`RedisKeyBuilder`만 다음 canonical shape를 만든다. + +```text +ca:::::hv:kv:{}:: +``` + +민감한 사용자/tenant/composite 값은 raw key에 넣지 않는다. length-prefixed canonical bytes를 +HMAC-SHA-256으로 digest한다. random opaque identifier는 SHA-256을 사용할 수 있다. builder는 slug, +version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다. + +## Atomic program foundation + +`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout +certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire, +set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로 +처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다. +Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다. +Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic +port adapter가 내부에서만 이 facade를 사용한다. +따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할 +수 있는 production bean이나 application port가 아니다. + +`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저 +호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는 +client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을 +확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은 +compare-and-delete의 실제 atomic execution을 검증한다. + +## Managed runtime과 semantic region + +`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`이 +단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가 +`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을 +명시함으로써 Spring configuration 처리 순서에 따라 managed/custom client 선택이 달라지지 않는다. +Managed runtime은 reconnect 시 pending command를 replay하지 않고, disconnected command를 +pre-send 거부하며, request queue와 동시 outstanding command를 같은 finite bound로 제한한다. +`RedisStringCacheRegion`은 `CacheRegionPort` bean으로 제공되며 다음 결과를 +구분한다. + +- positive hit, authoritative negative hit, normal miss; +- unknown/corrupt/retired envelope와 fail-fast future envelope; +- read unavailable/overloaded와 mutation not-applied/indeterminate; +- invalidated와 already absent. + +opaque source revision에는 대소 비교 의미가 없으므로 +`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고 +`NOT_RECORDED_PROVIDER_POLICY`를 반환한다. + +Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시 +검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다. + +추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과 +`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained +request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며, +`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는 +timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다. +timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로 +최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은 +두 번째 항이 첫 번째 항을 넘지 않게 해 최대 약 2배 population으로 제한한다. + +read는 raw `GET`을 사용하지 않는다. 고정 Lua read가 `GETRANGE(0, maximum-envelope-bytes)`로 +Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1` 이하로 자르고, 초과하면 +작은 오류 응답으로 바꾼다. 따라서 다른 writer가 같은 물리 키를 오염시켜도 전체 대용량 value를 +Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면 +`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다. + +## Legacy path + +기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이 +경로는 `Optional.empty()`로 miss와 backend failure를 합친다. managed runtime을 사용할 때 +legacy `put`에도 positive TTL을 적용하지만, 사용자 제공 legacy client의 TTL은 보장할 수 없으므로 +새 semantic cache port 구현의 기준으로 사용하지 않는다. + +## Verification + +```bash +cd src +./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain +./gradlew :adapter:outbound:cache-redis:redisServiceTest \ + -Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain +``` diff --git a/src/adapter/outbound/cache-redis/build.gradle b/src/adapter/outbound/cache-redis/build.gradle index bf8b1f1..c3e5b45 100644 --- a/src/adapter/outbound/cache-redis/build.gradle +++ b/src/adapter/outbound/cache-redis/build.gradle @@ -1,14 +1,34 @@ -plugins { id 'groovy' } dependencies { - implementation project(':domain-core') implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'io.lettuce:lettuce-core' implementation 'org.slf4j:slf4j-api' - - testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } -tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } + +tasks.named('test') { + useJUnitPlatform { + excludeTags 'redis-service' + } +} + +tasks.register('redisServiceTest', Test) { + group = 'verification' + description = 'Runs the explicit real Redis standalone qualification lane.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'redis-service' + } + ['redis.test.host', 'redis.test.port'].each { propertyName -> + String propertyValue = System.getProperty(propertyName) + if (propertyValue != null) { + systemProperty propertyName, propertyValue + } + } + shouldRunAfter tasks.named('test') +} diff --git a/src/adapter/outbound/cache-redis/gradle.lockfile b/src/adapter/outbound/cache-redis/gradle.lockfile index 85eb4c9..9e862f7 100644 --- a/src/adapter/outbound/cache-redis/gradle.lockfile +++ b/src/adapter/outbound/cache-redis/gradle.lockfile @@ -2,8 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor @@ -41,11 +41,21 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -59,13 +69,11 @@ org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath -org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -90,7 +98,7 @@ org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs @@ -109,14 +117,14 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -125,13 +133,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -149,7 +157,8 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java new file mode 100644 index 0000000..3ac0faf --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java @@ -0,0 +1,292 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.RedisCommandInterruptedException; +import io.lettuce.core.RedisCommandTimeoutException; +import io.lettuce.core.RedisConnectionException; +import io.lettuce.core.RedisConnectionStateListener; +import io.lettuce.core.RedisException; +import io.lettuce.core.RedisURI; +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.SetArgs; +import io.lettuce.core.TimeoutOptions; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.codec.ByteArrayCodec; +import java.net.SocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +/** Managed standalone Lettuce connection shared by cache and typed Lua facilities. */ +final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable { + + private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE"; + private static final byte[] BOUNDED_GET_SCRIPT = + """ + local limit = tonumber(ARGV[1]) + local value = redis.call('GETRANGE', KEYS[1], 0, limit) + if #value > limit then + return redis.error_reply('CA_VALUE_TOO_LARGE') + end + if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then + return false + end + return value + """ + .getBytes(StandardCharsets.UTF_8); + + private final io.lettuce.core.RedisClient client; + private final StatefulRedisConnection connection; + private final RedisCommands commands; + private final Duration legacyTtl; + private final Duration shutdownTimeout; + private final AtomicBoolean connected = new AtomicBoolean(true); + private final RedisCommandAdmission commandAdmission; + private final int maximumReadableValueBytes; + private final int maximumCommandBytes; + private final AtomicBoolean closed = new AtomicBoolean(); + + private LettuceRedisRuntime( + io.lettuce.core.RedisClient client, + StatefulRedisConnection connection, + RedisRuntimeSettings settings) { + this.client = client; + this.connection = connection; + this.commands = connection.sync(); + this.legacyTtl = settings.positiveTtl(); + this.shutdownTimeout = settings.commandTimeout(); + this.commandAdmission = + new RedisCommandAdmission( + settings.maximumQueuedCommands(), settings.maximumInFlightBytes()); + this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32; + this.maximumCommandBytes = settings.maximumValueBytes() + 2048; + connection.addListener( + new RedisConnectionStateListener() { + @Override + public void onRedisConnected( + io.lettuce.core.RedisChannelHandler connection, SocketAddress remoteAddress) { + connected.set(true); + } + + @Override + public void onRedisDisconnected(io.lettuce.core.RedisChannelHandler connection) { + connected.set(false); + } + }); + } + + static LettuceRedisRuntime connect(RedisRuntimeSettings settings) { + RedisURI uri = redisUri(settings); + io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri); + client.setOptions(clientOptions(settings)); + try { + StatefulRedisConnection connection = + client.connect(ByteArrayCodec.INSTANCE, uri); + return new LettuceRedisRuntime(client, connection, settings); + } catch (RuntimeException exception) { + client.shutdown(Duration.ZERO, settings.commandTimeout()); + throw exception; + } + } + + static RedisURI redisUri(RedisRuntimeSettings settings) { + RedisURI.Builder builder = + RedisURI.Builder.redis(settings.host(), settings.port()) + .withTimeout(settings.commandTimeout()); + if (!settings.password().isBlank()) { + builder.withPassword(settings.password().toCharArray()); + } + return builder.build(); + } + + static ClientOptions clientOptions(RedisRuntimeSettings settings) { + return ClientOptions.builder() + .autoReconnect(true) + .replayFilter(ignored -> true) + .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) + .requestQueueSize(settings.maximumQueuedCommands()) + .timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout())) + .build(); + } + + @Override + public Optional read(String key) { + byte[] value = get(key.getBytes(StandardCharsets.UTF_8)); + return value == null + ? Optional.empty() + : Optional.of(new String(value, StandardCharsets.UTF_8)); + } + + @Override + public void write(String key, String value) { + set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl); + } + + @Override + public byte[] get(byte[] key) { + byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII); + try { + byte[] value = + execute( + false, + reservationBytes( + maximumReadableValueBytes, + List.of(BOUNDED_GET_SCRIPT), + List.of(key), + List.of(limit)), + () -> + commands.eval( + BOUNDED_GET_SCRIPT, + ScriptOutputType.VALUE, + new byte[][] {key.clone()}, + limit)); + return value == null ? null : value.clone(); + } catch (RedisCommandExecutionException exception) { + if (exception.getMessage() != null + && exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) { + throw new RedisValueTooLargeException(); + } + throw exception; + } + } + + @Override + public void set(byte[] key, byte[] value, Duration timeToLive) { + String result = + execute( + true, + reservationBytes(64, List.of(key, value)), + () -> + commands.set( + key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis()))); + if (!"OK".equals(result)) { + throw new IllegalStateException("Redis SET did not acknowledge the mutation"); + } + } + + @Override + public long delete(byte[] key) { + return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone())); + } + + @Override + public byte[] evalSha(String sha1, List keys, List arguments) { + try { + return execute( + true, + reservationBytes(256, keys, arguments), + () -> + commands.evalsha( + sha1, + ScriptOutputType.VALUE, + keys.toArray(byte[][]::new), + arguments.toArray(byte[][]::new))); + } catch (io.lettuce.core.RedisNoScriptException exception) { + throw new RedisNoScriptException(); + } + } + + @Override + public byte[] eval(byte[] script, List keys, List arguments) { + return execute( + true, + reservationBytes(256, List.of(script), keys, arguments), + () -> + commands.eval( + script.clone(), + ScriptOutputType.VALUE, + keys.toArray(byte[][]::new), + arguments.toArray(byte[][]::new))); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + connection.close(); + } finally { + client.shutdown(Duration.ZERO, shutdownTimeout); + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Redis runtime is closed"); + } + } + + private T execute(boolean mutation, int reservationBytes, Supplier command) { + ensureOpen(); + if (reservationBytes > maximumCommandBytes) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.OVERLOADED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis command exceeds the retained-byte bound", + null); + } + if (!connected.get()) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis command rejected while disconnected", + null); + } + RedisCommandAdmission.Lease admission = commandAdmission.tryAcquire(reservationBytes); + if (admission == null) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.OVERLOADED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis command count or byte admission is saturated", + null); + } + try (admission) { + return command.get(); + } catch (RedisCommandExecutionException exception) { + throw exception; + } catch (RedisCommandInterruptedException exception) { + Thread.currentThread().interrupt(); + throw exception; + } catch (RedisCommandTimeoutException exception) { + throw commandFailure(mutation, "Redis command timed out", exception); + } catch (RedisConnectionException exception) { + throw commandFailure(mutation, "Redis connection failed during a command", exception); + } catch (RedisException exception) { + throw commandFailure(mutation, "Redis transport failed during a command", exception); + } + } + + private static RedisCommandFailureException commandFailure( + boolean mutation, String message, RuntimeException cause) { + return new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + mutation + ? RedisCommandFailureException.Certainty.INDETERMINATE + : RedisCommandFailureException.Certainty.NOT_APPLIED, + message, + cause); + } + + @SafeVarargs + private static int reservationBytes(int responseBytes, List... groups) { + long total = Math.max(1, responseBytes); + for (List group : groups) { + for (byte[] value : group) { + if (value == null) { + return Integer.MAX_VALUE; + } + total += value.length; + if (total > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + } + } + return (int) total; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java new file mode 100644 index 0000000..bc548ed --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Typed facade for bounded owner-safe and expirable Redis mutations. */ +final class RedisAtomicPrimitives { + + private static final int MAXIMUM_OWNER_BYTES = 128; + private static final int MAXIMUM_OPERATION_ID_BYTES = 128; + private static final int MAXIMUM_VALUE_BYTES = 1_048_576; + private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis(); + + private final RedisProgramCatalog catalog; + private final RedisProgramExecutor executor; + + RedisAtomicPrimitives(RedisProgramCatalog catalog, RedisProgramExecutor executor) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = Objects.requireNonNull(executor, "executor must be non-null"); + } + + CompareDeleteResult compareAndDelete(String key, byte[] expectedOwner) { + byte[] keyBytes = key(key); + byte[] owner = bounded(expectedOwner, MAXIMUM_OWNER_BYTES, "expected owner"); + String status = execute(RedisProgramId.COMPARE_AND_DELETE, List.of(keyBytes), List.of(owner)); + return parse(RedisProgramId.COMPARE_AND_DELETE, status, CompareDeleteResult.class); + } + + CompareExpireResult compareAndExpire(String key, byte[] expectedOwner, Duration timeToLive) { + byte[] keyBytes = key(key); + byte[] owner = bounded(expectedOwner, MAXIMUM_OWNER_BYTES, "expected owner"); + byte[] ttl = ttl(timeToLive); + String status = + execute(RedisProgramId.COMPARE_AND_EXPIRE, List.of(keyBytes), List.of(owner, ttl)); + return parse(RedisProgramId.COMPARE_AND_EXPIRE, status, CompareExpireResult.class); + } + + SetIfAbsentResult setIfAbsentWithTtl( + String key, byte[] value, Duration timeToLive, String operationId) { + byte[] keyBytes = key(key); + byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value"); + byte[] ttl = ttl(timeToLive); + byte[] operation = + bounded( + Objects.requireNonNull(operationId, "operationId must be non-null") + .getBytes(StandardCharsets.UTF_8), + MAXIMUM_OPERATION_ID_BYTES, + "operationId"); + String status = + execute( + RedisProgramId.SET_IF_ABSENT_WITH_TTL, + List.of(keyBytes), + List.of(boundedValue, ttl, operation)); + return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class); + } + + private String execute(RedisProgramId id, List keys, List arguments) { + RedisProgramDescriptor descriptor = catalog.descriptor(id); + if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { + throw new IllegalStateException("typed Redis program signature drift for " + id.externalId()); + } + return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments)); + } + + private static byte[] key(String key) { + Objects.requireNonNull(key, "key must be non-null"); + return bounded(key.getBytes(StandardCharsets.UTF_8), 512, "key"); + } + + private static byte[] ttl(Duration timeToLive) { + Objects.requireNonNull(timeToLive, "timeToLive must be non-null"); + long milliseconds; + try { + milliseconds = timeToLive.toMillis(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("TTL exceeds supported range", exception); + } + if (milliseconds < 1 || milliseconds > MAXIMUM_TTL_MILLIS) { + throw new IllegalArgumentException( + "TTL must be between 1 and " + MAXIMUM_TTL_MILLIS + " milliseconds"); + } + return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] bounded(byte[] value, int maximumBytes, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.length < 1 || value.length > maximumBytes) { + throw new IllegalArgumentException(field + " must contain 1.." + maximumBytes + " bytes"); + } + return value.clone(); + } + + private static > E parse( + RedisProgramId id, String status, Class resultType) { + try { + return Enum.valueOf(resultType, status); + } catch (IllegalArgumentException | NullPointerException exception) { + throw new RedisProgramCompatibilityException(id, status); + } + } + + enum CompareDeleteResult { + DELETED, + ABSENT, + NOT_OWNER, + WRONG_TYPE, + INVALID + } + + enum CompareExpireResult { + RENEWED, + ABSENT, + NOT_OWNER, + WRONG_TYPE, + INVALID + } + + enum SetIfAbsentResult { + SET, + EXISTS, + WRONG_TYPE, + INVALID + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java new file mode 100644 index 0000000..4873bf4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; + +/** Minimal binary Redis command surface owned entirely by this adapter. */ +interface RedisBinaryCommands { + + byte[] get(byte[] key); + + void set(byte[] key, byte[] value, Duration timeToLive); + + long delete(byte[] key); + + byte[] evalSha(String sha1, List keys, List arguments); + + byte[] eval(byte[] script, List keys, List arguments); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java index 9783299..a4b4b8b 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java @@ -1,7 +1,11 @@ package dev.caskeleton.adapter.outbound.cache.redis; import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheRegionPort; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -18,9 +22,56 @@ import org.springframework.context.annotation.Configuration; * AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs * therefore never need to know about each other — a new backend is new files only. */ -@Configuration +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(RedisRuntimeSettings.class) public class RedisCacheAdapterConfig { + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty( + name = "app.cache.redis.client-mode", + havingValue = "managed", + matchIfMissing = true) + static class ManagedRedisRuntimeConfig { + + @Bean(destroyMethod = "close") + @ConditionalOnProperty( + name = "app.cache.redis.enabled", + havingValue = "true", + matchIfMissing = false) + LettuceRedisRuntime lettuceRedisRuntime(RedisRuntimeSettings settings) { + settings.hmacSecret(); + return LettuceRedisRuntime.connect(settings); + } + } + + @Bean + @ConditionalOnBean(LettuceRedisRuntime.class) + @ConditionalOnProperty( + name = "app.cache.redis.enabled", + havingValue = "true", + matchIfMissing = false) + CacheRegionPort redisStringCacheRegion( + LettuceRedisRuntime runtime, RedisRuntimeSettings settings) { + RedisKeyNamespace namespace = + new RedisKeyNamespace( + settings.namespaceApplication(), + settings.namespaceEnvironment(), + "cache", + settings.semanticRegion(), + 1, + 1, + "entry", + 512); + return new RedisStringCacheRegion( + new RedisCacheRegionPolicy( + namespace, + settings.hmacSecret(), + settings.positiveTtl(), + settings.negativeTtl(), + settings.maximumValueBytes()), + runtime); + } + @Bean @ConditionalOnProperty( name = "app.cache.redis.enabled", diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java new file mode 100644 index 0000000..c4046d1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java @@ -0,0 +1,158 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheLookup; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Objects; + +/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */ +final class RedisCacheEnvelopeCodec { + + private static final int MAGIC = 0x43414348; + private static final byte VERSION = 1; + private static final byte POSITIVE = 1; + private static final byte NEGATIVE = 2; + private static final int CONTENT_HEADER_BYTES = + Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES; + private static final int DIGEST_BYTES = 32; + + private RedisCacheEnvelopeCodec() {} + + static byte[] positive(String value, String sourceRevision, int maximumValueBytes) { + return encode( + POSITIVE, + utf8(Objects.requireNonNull(value, "value must be non-null")), + sourceRevision, + maximumValueBytes); + } + + static byte[] negative( + AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) { + Objects.requireNonNull(reason, "reason must be non-null"); + return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes); + } + + static Decoded decode(byte[] envelope, int maximumValueBytes) { + if (envelope == null + || envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES + || envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } + try { + ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES); + if (buffer.getInt() != MAGIC) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } + byte version = buffer.get(); + if (version > VERSION) { + return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION); + } + if (version < VERSION) { + return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION); + } + byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES)); + byte[] actualDigest = + Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length); + if (!MessageDigest.isEqual(expectedDigest, actualDigest)) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE); + } + byte type = buffer.get(); + int revisionSize = Short.toUnsignedInt(buffer.getShort()); + int payloadSize = buffer.getInt(); + if (revisionSize < 1 + || revisionSize > 512 + || payloadSize < 1 + || payloadSize > maximumValueBytes + || buffer.remaining() != revisionSize + payloadSize) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } + byte[] revision = new byte[revisionSize]; + byte[] payload = new byte[payloadSize]; + buffer.get(revision); + buffer.get(payload); + String sourceRevision = strictUtf8(revision); + if (!validSourceRevision(sourceRevision)) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } + if (type == POSITIVE) { + return new Positive(strictUtf8(payload), sourceRevision); + } + if (type == NEGATIVE) { + return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload))); + } + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } catch (IllegalArgumentException | CharacterCodingException exception) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } + } + + private static byte[] encode( + byte type, byte[] payload, String sourceRevision, int maximumValueBytes) { + byte[] revision = + utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null")); + if (!validSourceRevision(sourceRevision) || revision.length > 512) { + throw new IllegalArgumentException( + "sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes"); + } + if (payload.length < 1 || payload.length > maximumValueBytes) { + throw new IllegalArgumentException("cache payload exceeds configured maximum bytes"); + } + byte[] content = + ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length) + .putInt(MAGIC) + .put(VERSION) + .put(type) + .putShort((short) revision.length) + .putInt(payload.length) + .put(revision) + .put(payload) + .array(); + return ByteBuffer.allocate(content.length + DIGEST_BYTES) + .put(content) + .put(sha256(content)) + .array(); + } + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static String strictUtf8(byte[] value) throws CharacterCodingException { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(value)) + .toString(); + } + + private static boolean validSourceRevision(String sourceRevision) { + return !sourceRevision.isBlank() && sourceRevision.length() <= 128; + } + + private static byte[] sha256(byte[] content) { + try { + return MessageDigest.getInstance("SHA-256").digest(content); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable for cache envelope", exception); + } + } + + private static Incompatible incompatible(CacheLookup.SchemaCategory category) { + return new Incompatible(category); + } + + sealed interface Decoded permits Positive, Negative, Incompatible {} + + record Positive(String value, String sourceRevision) implements Decoded {} + + record Negative(AuthoritativeAbsence reason) implements Decoded {} + + record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {} +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java new file mode 100644 index 0000000..edc02f5 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import java.time.Duration; +import java.util.Objects; + +/** Immutable key, TTL and envelope bounds for one semantic string cache region. */ +final class RedisCacheRegionPolicy { + + private final RedisKeyNamespace namespace; + private final byte[] hmacSecret; + private final Duration positiveTtl; + private final Duration negativeTtl; + private final int maximumValueBytes; + + RedisCacheRegionPolicy( + RedisKeyNamespace namespace, + byte[] hmacSecret, + Duration positiveTtl, + Duration negativeTtl, + int maximumValueBytes) { + this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null"); + Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"); + if (hmacSecret.length < 32) { + throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes"); + } + this.hmacSecret = hmacSecret.clone(); + this.positiveTtl = positive(positiveTtl, "positiveTtl"); + this.negativeTtl = positive(negativeTtl, "negativeTtl"); + if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) { + throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216"); + } + this.maximumValueBytes = maximumValueBytes; + } + + RedisKeyNamespace namespace() { + return namespace; + } + + byte[] hmacSecret() { + return hmacSecret.clone(); + } + + Duration positiveTtl() { + return positiveTtl; + } + + Duration negativeTtl() { + return negativeTtl; + } + + int maximumValueBytes() { + return maximumValueBytes; + } + + private static Duration positive(Duration value, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException(field + " must be positive and at most 30 days"); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandAdmission.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandAdmission.java new file mode 100644 index 0000000..6e0cc8e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandAdmission.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Immediate dual count/byte admission for commands retained by the managed connection. */ +final class RedisCommandAdmission { + + private final Semaphore commands; + private final Semaphore bytes; + + RedisCommandAdmission(int maximumCommands, int maximumBytes) { + commands = new Semaphore(maximumCommands); + bytes = new Semaphore(maximumBytes); + } + + Lease tryAcquire(int reservationBytes) { + if (reservationBytes < 1 || !commands.tryAcquire()) { + return null; + } + if (!bytes.tryAcquire(reservationBytes)) { + commands.release(); + return null; + } + return new Lease(reservationBytes); + } + + final class Lease implements AutoCloseable { + + private final int reservationBytes; + private final AtomicBoolean closed = new AtomicBoolean(); + + private Lease(int reservationBytes) { + this.reservationBytes = reservationBytes; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + bytes.release(reservationBytes); + commands.release(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java new file mode 100644 index 0000000..c6e6562 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Adapter-internal transport failure with explicit overload and mutation certainty. */ +final class RedisCommandFailureException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final Kind kind; + private final Certainty certainty; + + RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) { + super(message, cause); + this.kind = kind; + this.certainty = certainty; + } + + Kind kind() { + return kind; + } + + Certainty certainty() { + return certainty; + } + + enum Kind { + UNAVAILABLE, + OVERLOADED + } + + enum Certainty { + NOT_APPLIED, + INDETERMINATE + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java new file mode 100644 index 0000000..453c617 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +/** Executes an exact catalog script through EVALSHA, with EVAL allowed only after NOSCRIPT. */ +final class RedisLuaProgramExecutor implements RedisProgramExecutor { + + private static final HexFormat HEX = HexFormat.of(); + + private final RedisProgramCatalog catalog; + private final RedisBinaryCommands commands; + + RedisLuaProgramExecutor(RedisProgramCatalog catalog, RedisBinaryCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + } + + @Override + public String execute( + RedisProgramDescriptor descriptor, List keys, List arguments) { + Objects.requireNonNull(descriptor, "descriptor must be non-null"); + if (catalog.descriptor(descriptor.id()) != descriptor) { + throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog"); + } + validate(descriptor, keys, arguments); + byte[] result; + try { + result = commands.evalSha(sha1(descriptor.scriptBytes()), keys, arguments); + } catch (RedisNoScriptException noScript) { + result = commands.eval(descriptor.scriptBytes(), keys, arguments); + } + if (result == null || result.length == 0 || result.length > 128) { + throw new IllegalStateException("Redis program returned an invalid status payload"); + } + String status = new String(result, StandardCharsets.US_ASCII); + if (!descriptor.statuses().contains(status)) { + throw new RedisProgramCompatibilityException(descriptor.id(), status); + } + return status; + } + + private static void validate( + RedisProgramDescriptor descriptor, List keys, List arguments) { + Objects.requireNonNull(keys, "keys must be non-null"); + Objects.requireNonNull(arguments, "arguments must be non-null"); + if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { + throw new IllegalArgumentException("Redis program signature does not match descriptor"); + } + for (byte[] key : keys) { + bounded(key, descriptor.maximumKeyBytes(), "key"); + } + for (byte[] argument : arguments) { + bounded(argument, descriptor.maximumArgumentBytes(), "argument"); + } + } + + private static void bounded(byte[] value, int maximumBytes, String field) { + if (value == null || value.length < 1 || value.length > maximumBytes) { + throw new IllegalArgumentException("Redis program " + field + " is out of bounds"); + } + } + + private static String sha1(byte[] script) { + try { + return HEX.formatHex(MessageDigest.getInstance("SHA-1").digest(script)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-1 unavailable for Redis script identity", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNoScriptException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNoScriptException.java new file mode 100644 index 0000000..fc9e277 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNoScriptException.java @@ -0,0 +1,7 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Internal signal used only to authorize the bounded EVAL fallback. */ +final class RedisNoScriptException extends RuntimeException { + + private static final long serialVersionUID = 1L; +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java new file mode 100644 index 0000000..9cd7ae7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HexFormat; +import java.util.Map; +import java.util.Set; + +/** Closed catalog that binds typed program IDs to immutable versioned Lua resources. */ +final class RedisProgramCatalog { + + private static final HexFormat HEX = HexFormat.of(); + + private final Map descriptors; + + private RedisProgramCatalog(Map descriptors) { + this.descriptors = Map.copyOf(descriptors); + } + + static RedisProgramCatalog foundation() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.COMPARE_AND_DELETE, + descriptor( + RedisProgramId.COMPARE_AND_DELETE, + 1, + 1, + 512, + 128, + Set.of("DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.COMPARE_AND_EXPIRE, + descriptor( + RedisProgramId.COMPARE_AND_EXPIRE, + 1, + 2, + 512, + 128, + Set.of("RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.SET_IF_ABSENT_WITH_TTL, + descriptor( + RedisProgramId.SET_IF_ABSENT_WITH_TTL, + 1, + 3, + 512, + 1_048_576, + Set.of("SET", "EXISTS", "WRONG_TYPE", "INVALID"))); + return new RedisProgramCatalog(descriptors); + } + + RedisProgramDescriptor descriptor(RedisProgramId id) { + RedisProgramDescriptor descriptor = descriptors.get(id); + if (descriptor == null) { + throw new IllegalArgumentException("unknown Redis program id: " + id); + } + return descriptor; + } + + Collection descriptors() { + return descriptors.values(); + } + + private static RedisProgramDescriptor descriptor( + RedisProgramId id, + int keyCount, + int argumentCount, + int maximumKeyBytes, + int maximumArgumentBytes, + Set statuses) { + byte[] script = readResource(id.scriptResource()); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + keyCount, + argumentCount, + maximumKeyBytes, + maximumArgumentBytes, + statuses); + } + + private static byte[] readResource(String resource) { + ClassLoader loader = RedisProgramCatalog.class.getClassLoader(); + try (InputStream input = loader.getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("missing Redis program resource: " + resource); + } + return input.readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("cannot read Redis program resource: " + resource, exception); + } + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCompatibilityException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCompatibilityException.java new file mode 100644 index 0000000..31da4fe --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCompatibilityException.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Raised when runtime program output is not part of the compiled program contract. */ +public final class RedisProgramCompatibilityException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + RedisProgramCompatibilityException(RedisProgramId id, String status) { + super("Redis program " + id.externalId() + " returned unknown status: " + status); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java new file mode 100644 index 0000000..d029756 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; +import java.util.Set; + +/** Immutable signature and exact source digest for one versioned atomic program. */ +final class RedisProgramDescriptor { + + private final RedisProgramId id; + private final String sha256; + private final byte[] scriptBytes; + private final int keyCount; + private final int argumentCount; + private final int maximumKeyBytes; + private final int maximumArgumentBytes; + private final Set statuses; + + RedisProgramDescriptor( + RedisProgramId id, + String sha256, + byte[] scriptBytes, + int keyCount, + int argumentCount, + int maximumKeyBytes, + int maximumArgumentBytes, + Set statuses) { + this.id = Objects.requireNonNull(id, "id must be non-null"); + if (sha256 == null || !sha256.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("sha256 must be 64 lowercase hexadecimal characters"); + } + this.sha256 = sha256; + Objects.requireNonNull(scriptBytes, "scriptBytes must be non-null"); + if (scriptBytes.length == 0) { + throw new IllegalArgumentException("scriptBytes must be non-empty"); + } + this.scriptBytes = scriptBytes.clone(); + if (keyCount < 1 || argumentCount < 1) { + throw new IllegalArgumentException("program key and argument counts must be positive"); + } + this.keyCount = keyCount; + this.argumentCount = argumentCount; + if (maximumKeyBytes < 1 || maximumArgumentBytes < 1) { + throw new IllegalArgumentException("program byte bounds must be positive"); + } + this.maximumKeyBytes = maximumKeyBytes; + this.maximumArgumentBytes = maximumArgumentBytes; + this.statuses = Set.copyOf(statuses); + if (this.statuses.isEmpty()) { + throw new IllegalArgumentException("program statuses must be non-empty"); + } + } + + RedisProgramId id() { + return id; + } + + String sha256() { + return sha256; + } + + byte[] scriptBytes() { + return scriptBytes.clone(); + } + + int keyCount() { + return keyCount; + } + + int argumentCount() { + return argumentCount; + } + + int maximumKeyBytes() { + return maximumKeyBytes; + } + + int maximumArgumentBytes() { + return maximumArgumentBytes; + } + + Set statuses() { + return statuses; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java new file mode 100644 index 0000000..bf1af80 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; + +/** + * Adapter-internal execution seam. Implementations may use Functions or EVALSHA, but application + * code must only depend on semantic ports and typed facades. + */ +@FunctionalInterface +interface RedisProgramExecutor { + + String execute(RedisProgramDescriptor descriptor, List keys, List arguments); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java new file mode 100644 index 0000000..e811fdf --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Versioned Redis atomic programs available in the foundation catalog. */ +enum RedisProgramId { + COMPARE_AND_DELETE("compare-and-delete-v1", "redis/scripts/compare-and-delete-v1.lua"), + COMPARE_AND_EXPIRE("compare-and-expire-v1", "redis/scripts/compare-and-expire-v1.lua"), + SET_IF_ABSENT_WITH_TTL( + "set-if-absent-with-ttl-v1", "redis/scripts/set-if-absent-with-ttl-v1.lua"); + + private final String externalId; + private final String scriptResource; + + RedisProgramId(String externalId, String scriptResource) { + this.externalId = externalId; + this.scriptResource = scriptResource; + } + + String externalId() { + return externalId; + } + + String scriptResource() { + return scriptResource; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java new file mode 100644 index 0000000..e01f14e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Base64; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Typed standalone Redis runtime and semantic cache settings. */ +@ConfigurationProperties(prefix = "app.cache.redis") +public record RedisRuntimeSettings( + boolean enabled, + ClientMode clientMode, + String host, + int port, + String password, + String keyHmacSecret, + Duration commandTimeout, + Duration positiveTtl, + Duration negativeTtl, + String namespaceApplication, + String namespaceEnvironment, + String semanticRegion, + int maximumValueBytes, + int maximumQueuedCommands, + int maximumInFlightBytes) { + + private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30); + private static final Duration MAXIMUM_TTL = Duration.ofDays(30); + + @ConstructorBinding + public RedisRuntimeSettings { + clientMode = clientMode == null ? ClientMode.MANAGED : clientMode; + String configuredHost = host == null ? "" : host.trim(); + if (enabled && clientMode == ClientMode.MANAGED && configuredHost.isEmpty()) { + throw new IllegalArgumentException( + "Redis host must be configured when managed Redis is enabled"); + } + host = configuredHost.isEmpty() ? "localhost" : configuredHost; + port = port == 0 ? 6379 : port; + password = password == null ? "" : password; + keyHmacSecret = keyHmacSecret == null ? "" : keyHmacSecret; + commandTimeout = commandTimeout == null ? Duration.ofSeconds(2) : commandTimeout; + positiveTtl = positiveTtl == null ? Duration.ofMinutes(5) : positiveTtl; + negativeTtl = negativeTtl == null ? Duration.ofSeconds(60) : negativeTtl; + namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); + namespaceEnvironment = defaultText(namespaceEnvironment, "local"); + semanticRegion = defaultText(semanticRegion, "default"); + maximumValueBytes = maximumValueBytes == 0 ? 1_048_576 : maximumValueBytes; + maximumQueuedCommands = maximumQueuedCommands == 0 ? 8 : maximumQueuedCommands; + maximumInFlightBytes = maximumInFlightBytes == 0 ? 16_777_216 : maximumInFlightBytes; + if (host.length() > 253 + || host.chars().anyMatch(Character::isWhitespace) + || host.contains("/") + || host.contains("\\")) { + throw new IllegalArgumentException("Redis host is invalid"); + } + if (port < 1 || port > 65_535) { + throw new IllegalArgumentException("Redis port must be in 1..65535"); + } + positive(commandTimeout, MAXIMUM_TIMEOUT, "Redis command timeout"); + positive(positiveTtl, MAXIMUM_TTL, "Redis positive TTL"); + positive(negativeTtl, MAXIMUM_TTL, "Redis negative TTL"); + slug(namespaceApplication, "Redis namespace application"); + slug(namespaceEnvironment, "Redis namespace environment"); + slug(semanticRegion, "Redis semantic region"); + if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) { + throw new IllegalArgumentException("Redis maximum value bytes must be in 1..16777216"); + } + if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) { + throw new IllegalArgumentException("Redis maximum queued commands must be in 1..4096"); + } + if (maximumInFlightBytes < maximumValueBytes + 1024 || maximumInFlightBytes > 268_435_456) { + throw new IllegalArgumentException( + "Redis maximum in-flight bytes must cover one maximum value and be <= 268435456"); + } + long maximumRetainedCommandBytes = (long) maximumQueuedCommands * (maximumValueBytes + 2048L); + if (maximumRetainedCommandBytes > maximumInFlightBytes) { + throw new IllegalArgumentException( + "Redis queued-command count and maximum value exceed the in-flight byte bound"); + } + } + + RedisRuntimeSettings( + boolean enabled, + ClientMode clientMode, + String host, + int port, + String password, + String keyHmacSecret, + Duration commandTimeout, + Duration positiveTtl, + Duration negativeTtl, + String namespaceApplication, + String namespaceEnvironment, + String semanticRegion, + int maximumValueBytes) { + this( + enabled, + clientMode, + host, + port, + password, + keyHmacSecret, + commandTimeout, + positiveTtl, + negativeTtl, + namespaceApplication, + namespaceEnvironment, + semanticRegion, + maximumValueBytes, + 8, + 16_777_216); + } + + /** Selects the module-owned Lettuce runtime or an explicitly supplied {@link RedisClient}. */ + public enum ClientMode { + MANAGED, + EXTERNAL + } + + byte[] hmacSecret() { + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(keyHmacSecret); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Redis key HMAC secret must be valid Base64", exception); + } + if (decoded.length < 32) { + throw new IllegalArgumentException("Redis key HMAC secret must contain at least 32 bytes"); + } + return decoded; + } + + private static void positive(Duration value, Duration maximum, String field) { + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + } + + private static void slug(String value, String field) { + if (!value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException(field + " has invalid format"); + } + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java new file mode 100644 index 0000000..a6b8ec7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Incompatible; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Negative; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Positive; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordIntent; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.cache.CacheRegionPort; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +/** Semantic string-cache reference adapter using versioned envelopes and finite TTLs. */ +final class RedisStringCacheRegion implements CacheRegionPort { + + private final RedisCacheRegionPolicy policy; + private final RedisBinaryCommands commands; + + RedisStringCacheRegion(RedisCacheRegionPolicy policy, RedisBinaryCommands commands) { + this.policy = Objects.requireNonNull(policy, "policy must be non-null"); + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + } + + @Override + public CacheLookup lookup(String key) { + byte[] physicalKey = physicalKey(key); + byte[] envelope; + try { + envelope = commands.get(physicalKey); + } catch (RedisValueTooLargeException exception) { + return new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD); + } catch (RedisCommandFailureException exception) { + return new CacheLookup.Unavailable<>( + exception.kind() == RedisCommandFailureException.Kind.OVERLOADED + ? CacheLookup.UnavailabilityReason.OVERLOADED + : CacheLookup.UnavailabilityReason.UNAVAILABLE, + certainty(exception)); + } + if (envelope == null) { + return new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT); + } + var decoded = RedisCacheEnvelopeCodec.decode(envelope, policy.maximumValueBytes()); + if (decoded instanceof Positive positive) { + return new CacheLookup.Hit<>( + positive.value(), CacheLookup.Freshness.FRESH, positive.sourceRevision()); + } + if (decoded instanceof Negative negative) { + return new CacheLookup.NegativeHit<>(negative.reason()); + } + Incompatible incompatible = (Incompatible) decoded; + return new CacheLookup.IncompatibleSchema<>( + incompatible.category(), + incompatible.category() == CacheLookup.SchemaCategory.FUTURE_VERSION + ? CacheLookup.SchemaPolicy.FAIL_FAST + : CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD); + } + + @Override + public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) { + Objects.requireNonNull(metadata, "metadata must be non-null"); + if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) { + return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; + } + byte[] physicalKey = physicalKey(key); + byte[] envelope = + RedisCacheEnvelopeCodec.positive( + value, metadata.sourceRevision(), policy.maximumValueBytes()); + return set(physicalKey, envelope, policy.positiveTtl()); + } + + @Override + public CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + Objects.requireNonNull(metadata, "metadata must be non-null"); + if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) { + return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; + } + byte[] physicalKey = physicalKey(key); + byte[] envelope = + RedisCacheEnvelopeCodec.negative( + reason, metadata.sourceRevision(), policy.maximumValueBytes()); + return set(physicalKey, envelope, policy.negativeTtl()); + } + + @Override + public CacheInvalidationOutcome invalidate(String key) { + byte[] physicalKey = physicalKey(key); + try { + return commands.delete(physicalKey) > 0 + ? CacheInvalidationOutcome.INVALIDATED + : CacheInvalidationOutcome.ALREADY_ABSENT; + } catch (RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? CacheInvalidationOutcome.DEGRADED_UNAVAILABLE + : CacheInvalidationOutcome.INDETERMINATE; + } + } + + private CacheRecordOutcome set( + byte[] physicalKey, byte[] envelope, java.time.Duration timeToLive) { + try { + commands.set(physicalKey, envelope, timeToLive); + return CacheRecordOutcome.RECORDED; + } catch (RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? CacheRecordOutcome.DEGRADED_UNAVAILABLE + : CacheRecordOutcome.INDETERMINATE; + } + } + + private byte[] physicalKey(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("semantic cache key must be non-blank"); + } + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + policy.namespace().hashKeyVersion(), + policy.hmacSecret(), + List.of(key.getBytes(StandardCharsets.UTF_8))); + return RedisKeyBuilder.build(policy.namespace(), digest).getBytes(StandardCharsets.UTF_8); + } + + private static CacheLookup.OperationCertainty certainty(RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? CacheLookup.OperationCertainty.NOT_APPLIED + : CacheLookup.OperationCertainty.INDETERMINATE; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisValueTooLargeException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisValueTooLargeException.java new file mode 100644 index 0000000..230f0b9 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisValueTooLargeException.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Signals that Redis contains a value larger than this runtime is allowed to receive. */ +final class RedisValueTooLargeException extends RuntimeException { + + RedisValueTooLargeException() { + super("Redis value exceeds the configured receive bound"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java new file mode 100644 index 0000000..b15cee9 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.cache.redis.key; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Sole physical key constructor for the Redis capability foundation. */ +public final class RedisKeyBuilder { + + private RedisKeyBuilder() {} + + public static String build(RedisKeyNamespace namespace, RedisKeyDigest digest) { + Objects.requireNonNull(namespace, "namespace must be non-null"); + Objects.requireNonNull(digest, "digest must be non-null"); + if (namespace.hashKeyVersion() != digest.hashKeyVersion()) { + throw new IllegalArgumentException("namespace and digest hash key version must match"); + } + String key = + "ca:%s:%s:%s:%s:hv%d:kv%d:{%s}:%s:%s" + .formatted( + namespace.application(), + namespace.environment(), + namespace.capability(), + namespace.region(), + namespace.hashKeyVersion(), + namespace.keyVersion(), + digest.slotTag(), + digest.resourceDigest(), + namespace.kind()); + int byteSize = key.getBytes(StandardCharsets.UTF_8).length; + if (byteSize > namespace.maximumKeyBytes()) { + throw new IllegalArgumentException( + "physical Redis key exceeds maximum bytes: " + + byteSize + + " > " + + namespace.maximumKeyBytes()); + } + return key; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java new file mode 100644 index 0000000..2fd0eef --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.outbound.cache.redis.key; + +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Precomputed digest and cluster slot tag; raw resource identifiers are never retained. */ +public record RedisKeyDigest(int hashKeyVersion, String slotTag, String resourceDigest) { + + private static final int MAXIMUM_COMPONENT_BYTES = 4_096; + private static final int MAXIMUM_CANONICAL_BYTES = 16_384; + private static final HexFormat HEX = HexFormat.of(); + + public RedisKeyDigest { + if (hashKeyVersion < 1 || hashKeyVersion > 9_999) { + throw new IllegalArgumentException("hashKeyVersion must be in 1..9999"); + } + if (slotTag == null || !slotTag.matches("[0-9a-f]{8}")) { + throw new IllegalArgumentException("slotTag must be 8 lowercase hexadecimal characters"); + } + if (resourceDigest == null || !resourceDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "resourceDigest must be 64 lowercase hexadecimal characters"); + } + } + + public static RedisKeyDigest opaque(int hashKeyVersion, List components) { + return fromBytes(hashKeyVersion, sha256(canonicalComponents(components))); + } + + public static RedisKeyDigest sensitive( + int hashKeyVersion, byte[] secret, List components) { + Objects.requireNonNull(secret, "secret must be non-null"); + if (secret.length < 32) { + throw new IllegalArgumentException("HMAC secret must contain at least 32 bytes"); + } + byte[] secretCopy = secret.clone(); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretCopy, "HmacSHA256")); + return fromBytes(hashKeyVersion, mac.doFinal(canonicalComponents(components))); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("HmacSHA256 unavailable", exception); + } finally { + Arrays.fill(secretCopy, (byte) 0); + } + } + + private static RedisKeyDigest fromBytes(int hashKeyVersion, byte[] digest) { + String hexadecimal = HEX.formatHex(digest); + return new RedisKeyDigest(hashKeyVersion, hexadecimal.substring(0, 8), hexadecimal); + } + + private static byte[] canonicalComponents(List components) { + Objects.requireNonNull(components, "components must be non-null"); + if (components.isEmpty()) { + throw new IllegalArgumentException("at least one digest component is required"); + } + int size = 0; + for (byte[] component : components) { + Objects.requireNonNull(component, "digest component must be non-null"); + if (component.length > MAXIMUM_COMPONENT_BYTES) { + throw new IllegalArgumentException("digest component exceeds maximum bytes"); + } + size = Math.addExact(size, Integer.BYTES + component.length); + if (size > MAXIMUM_CANONICAL_BYTES) { + throw new IllegalArgumentException("canonical digest input exceeds maximum bytes"); + } + } + ByteBuffer buffer = ByteBuffer.allocate(size); + for (byte[] component : components) { + buffer.putInt(component.length); + buffer.put(component); + } + return buffer.array(); + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyNamespace.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyNamespace.java new file mode 100644 index 0000000..161f15b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyNamespace.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.cache.redis.key; + +/** Validated non-sensitive namespace segments for one physical Redis key family. */ +public record RedisKeyNamespace( + String application, + String environment, + String capability, + String region, + int hashKeyVersion, + int keyVersion, + String kind, + int maximumKeyBytes) { + + public RedisKeyNamespace { + validateSlug(application, "application"); + validateSlug(environment, "environment"); + validateSlug(capability, "capability"); + validateSlug(region, "region"); + validateSlug(kind, "kind"); + if (hashKeyVersion < 1 || hashKeyVersion > 9_999) { + throw new IllegalArgumentException("hashKeyVersion must be in 1..9999"); + } + if (keyVersion < 1 || keyVersion > 9_999) { + throw new IllegalArgumentException("keyVersion must be in 1..9999"); + } + if (maximumKeyBytes < 1 || maximumKeyBytes > 4_096) { + throw new IllegalArgumentException("maximumKeyBytes must be in 1..4096 bytes"); + } + } + + private static void validateSlug(String value, String field) { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9-]{0,62}"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json new file mode 100644 index 0000000..ad74ddb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json @@ -0,0 +1,38 @@ +{ + "programSet": "ca-redis-programs-v1-foundation", + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 1, + "readiness": "R0", + "programs": [ + { + "id": "compare-and-delete-v1", + "scriptResource": "redis/scripts/compare-and-delete-v1.lua", + "sha256": "d0fa9beaa37353ec96be36e3158e06b33165b15489c67e9ca8e4800dac09b25a", + "keyCount": 1, + "argumentCount": 1, + "statuses": ["DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"], + "complexity": "O(1)", + "timeoutCertainty": "INDETERMINATE" + }, + { + "id": "compare-and-expire-v1", + "scriptResource": "redis/scripts/compare-and-expire-v1.lua", + "sha256": "5665fe349f2800c061ff3c86ec33ff11cb6706ee35c605b8e68db21cd08bd7e0", + "keyCount": 1, + "argumentCount": 2, + "statuses": ["RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"], + "complexity": "O(1)", + "timeoutCertainty": "INDETERMINATE" + }, + { + "id": "set-if-absent-with-ttl-v1", + "scriptResource": "redis/scripts/set-if-absent-with-ttl-v1.lua", + "sha256": "777014f7a23435b5701e2d0d286aef60a2f5d54a7836e94122527b28098dc010", + "keyCount": 1, + "argumentCount": 3, + "statuses": ["SET", "EXISTS", "WRONG_TYPE", "INVALID"], + "complexity": "O(1)", + "timeoutCertainty": "INDETERMINATE" + } + ] +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-delete-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-delete-v1.lua new file mode 100644 index 0000000..e2bc8d6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-delete-v1.lua @@ -0,0 +1,25 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +if #KEYS ~= 1 or #ARGV ~= 1 or string.len(ARGV[1]) == 0 or string.len(ARGV[1]) > 128 then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type == 'none' then + return 'ABSENT' +end +if current_type ~= 'string' then + return 'WRONG_TYPE' +end +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 'NOT_OWNER' +end + +redis.call('DEL', KEYS[1]) +return 'DELETED' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-expire-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-expire-v1.lua new file mode 100644 index 0000000..0c720f1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-expire-v1.lua @@ -0,0 +1,27 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local ttl = tonumber(ARGV[2]) +if #KEYS ~= 1 or #ARGV ~= 2 or string.len(ARGV[1]) == 0 or string.len(ARGV[1]) > 128 + or ttl == nil or ttl < 1 then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type == 'none' then + return 'ABSENT' +end +if current_type ~= 'string' then + return 'WRONG_TYPE' +end +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 'NOT_OWNER' +end + +redis.call('PEXPIRE', KEYS[1], ttl) +return 'RENEWED' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/set-if-absent-with-ttl-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/set-if-absent-with-ttl-v1.lua new file mode 100644 index 0000000..f21a0c6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/set-if-absent-with-ttl-v1.lua @@ -0,0 +1,24 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local ttl = tonumber(ARGV[2]) +if #KEYS ~= 1 or #ARGV ~= 3 or string.len(ARGV[1]) == 0 + or ttl == nil or ttl < 1 or string.len(ARGV[3]) == 0 or string.len(ARGV[3]) > 128 then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type ~= 'none' and current_type ~= 'string' then + return 'WRONG_TYPE' +end + +local applied = redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl, 'NX') +if applied then + return 'SET' +end +return 'EXISTS' diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java new file mode 100644 index 0000000..e0170e0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordIntent; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.codec.ByteArrayCodec; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-service") +class LettuceRedisRuntimeServiceTest { + + @Test + void executesRealTtlExpiryAndCatalogLuaAgainstStandaloneRedis() throws InterruptedException { + RedisRuntimeSettings settings = settings(); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings); + try { + RedisStringCacheRegion region = + new RedisStringCacheRegion( + new RedisCacheRegionPolicy( + new RedisKeyNamespace( + "ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512), + settings.hmacSecret(), + Duration.ofMillis(150), + Duration.ofSeconds(1), + 1024), + runtime); + + assertThat( + region.record( + "service-key", + "service-value", + new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + assertThat(region.lookup("service-key")) + .isEqualTo( + new CacheLookup.Hit<>("service-value", CacheLookup.Freshness.FRESH, "revision-1")); + awaitMiss(region, "service-key"); + + byte[] leaseKey = "ca:test:lease:{service}".getBytes(UTF_8); + runtime.set(leaseKey, "owner-1".getBytes(UTF_8), Duration.ofSeconds(5)); + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisProgramDescriptor compareDelete = + catalog.descriptors().stream() + .filter(descriptor -> descriptor.argumentCount() == 1) + .findFirst() + .orElseThrow(); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, runtime); + + assertThat( + executor.execute( + compareDelete, List.of(leaseKey), List.of("owner-1".getBytes(UTF_8)))) + .isEqualTo("DELETED"); + assertThat(runtime.get(leaseKey)).isNull(); + } finally { + runtime.close(); + } + + assertThatThrownBy(() -> runtime.get("closed".getBytes(UTF_8))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + } + + @Test + void rejectsAnOversizedBulkValueBeforeReturningItToTheSemanticDecoder() { + RedisRuntimeSettings settings = settings(); + RedisKeyNamespace namespace = + new RedisKeyNamespace("ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512); + byte[] semanticKey = "oversized-service-key".getBytes(UTF_8); + byte[] physicalKey = + RedisKeyBuilder.build( + namespace, + RedisKeyDigest.sensitive( + namespace.hashKeyVersion(), settings.hmacSecret(), List.of(semanticKey))) + .getBytes(UTF_8); + byte[] oversizedValue = new byte[settings.maximumValueBytes() + 4096]; + Arrays.fill(oversizedValue, (byte) 'x'); + + io.lettuce.core.RedisClient unboundedClient = + io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(settings)); + try (StatefulRedisConnection unboundedConnection = + unboundedClient.connect(ByteArrayCodec.INSTANCE); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) { + unboundedConnection.sync().set(physicalKey, oversizedValue); + + assertThatThrownBy(() -> runtime.get(physicalKey)) + .isInstanceOf(RedisValueTooLargeException.class); + + RedisStringCacheRegion region = + new RedisStringCacheRegion( + new RedisCacheRegionPolicy( + namespace, + settings.hmacSecret(), + Duration.ofMinutes(5), + Duration.ofSeconds(1), + settings.maximumValueBytes()), + runtime); + assertThat(region.lookup(new String(semanticKey, UTF_8))) + .isEqualTo( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + } finally { + unboundedClient.shutdown(Duration.ZERO, settings.commandTimeout()); + } + } + + private static void awaitMiss(RedisStringCacheRegion region, String key) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); + CacheLookup result; + do { + result = region.lookup(key); + if (result instanceof CacheLookup.Miss) { + assertThat(result).isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + return; + } + Thread.sleep(20); + } while (System.nanoTime() < deadline); + throw new AssertionError( + "Redis key did not expire within the qualification deadline: " + result); + } + + private static RedisRuntimeSettings settings() { + String host = requiredProperty("redis.test.host"); + int port = Integer.parseInt(requiredProperty("redis.test.port")); + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + host, + port, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "service", + 1024); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new AssertionError("real Redis lane requires -D" + name); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java new file mode 100644 index 0000000..0b3bf3e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisURI; +import java.time.Duration; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class LettuceRedisRuntimeTest { + + @Test + void buildsAnExactFiniteStandaloneRedisUri() { + RedisRuntimeSettings settings = + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "127.0.0.1", + 6380, + "secret-value", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024); + + RedisURI uri = LettuceRedisRuntime.redisUri(settings); + + assertThat(uri.getHost()).isEqualTo("127.0.0.1"); + assertThat(uri.getPort()).isEqualTo(6380); + assertThat(uri.getTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(uri.toString()).doesNotContain("secret-value"); + } + + @Test + void disablesReconnectReplayAndBoundsEveryOutstandingCommand() { + RedisRuntimeSettings settings = + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "127.0.0.1", + 6380, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024, + 17, + 65_536); + + ClientOptions options = LettuceRedisRuntime.clientOptions(settings); + + assertThat(options.isAutoReconnect()).isTrue(); + assertThat(options.getReplayFilter().test(null)).isTrue(); + assertThat(options.getDisconnectedBehavior()) + .isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS); + assertThat(options.getRequestQueueSize()).isEqualTo(17); + assertThat(options.getTimeoutOptions().isTimeoutCommands()).isTrue(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java new file mode 100644 index 0000000..1de3ba3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RedisAtomicPrimitivesTest { + + @Test + void mapsCompareDeleteStatusThroughTheTypedFacade() { + CapturingExecutor executor = new CapturingExecutor("NOT_OWNER"); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + + RedisAtomicPrimitives.CompareDeleteResult result = + primitives.compareAndDelete("lease-key", "owner-1".getBytes(UTF_8)); + + assertThat(result).isEqualTo(RedisAtomicPrimitives.CompareDeleteResult.NOT_OWNER); + assertThat(executor.programId).isEqualTo(RedisProgramId.COMPARE_AND_DELETE); + assertThat(executor.keys).containsExactly("lease-key".getBytes(UTF_8)); + assertThat(executor.arguments).containsExactly("owner-1".getBytes(UTF_8)); + } + + @Test + void validatesTtlAndArgumentBoundsBeforeCallingRedis() { + CapturingExecutor executor = new CapturingExecutor("RENEWED"); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + + assertThatThrownBy( + () -> + primitives.compareAndExpire("lease-key", "owner-1".getBytes(UTF_8), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TTL"); + assertThat(executor.calls).hasValue(0); + } + + @Test + void rejectsUnknownProgramStatusAsCompatibilityFailure() { + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives( + RedisProgramCatalog.foundation(), new CapturingExecutor("NEW_SERVER_STATUS")); + + assertThatThrownBy(() -> primitives.compareAndDelete("lease-key", "owner-1".getBytes(UTF_8))) + .isInstanceOf(RedisProgramCompatibilityException.class) + .hasMessageContaining("NEW_SERVER_STATUS"); + } + + private static final class CapturingExecutor implements RedisProgramExecutor { + + private final String status; + private final AtomicInteger calls = new AtomicInteger(); + private RedisProgramId programId; + private List keys; + private List arguments; + + private CapturingExecutor(String status) { + this.status = status; + } + + @Override + public String execute( + RedisProgramDescriptor descriptor, List keys, List arguments) { + calls.incrementAndGet(); + programId = descriptor.id(); + this.keys = keys; + this.arguments = arguments; + return status; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandAdmissionTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandAdmissionTest.java new file mode 100644 index 0000000..8f81cc3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandAdmissionTest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class RedisCommandAdmissionTest { + + @Test + void rejectsWhenEitherCommandCountOrRetainedBytesAreSaturatedAndReleasesExactlyOnce() { + RedisCommandAdmission admission = new RedisCommandAdmission(2, 100); + RedisCommandAdmission.Lease first = admission.tryAcquire(80); + + assertThat(first).isNotNull(); + assertThat(admission.tryAcquire(21)).isNull(); + + first.close(); + first.close(); + RedisCommandAdmission.Lease second = admission.tryAcquire(100); + assertThat(second).isNotNull(); + assertThat(admission.tryAcquire(1)).isNull(); + second.close(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java new file mode 100644 index 0000000..126f90c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RedisLuaProgramExecutorTest { + + @Test + void fallsBackToEvalOnlyWhenEvalShaReportsNoScript() { + FakeCommands commands = new FakeCommands(); + commands.noScript = true; + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); + RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog); + + String status = + executor.execute( + descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8))); + + assertThat(status).isEqualTo("DELETED"); + assertThat(commands.evalShaCalls).hasValue(1); + assertThat(commands.evalCalls).hasValue(1); + } + + @Test + void doesNotEvalAgainWhenCachedScriptExecutes() { + FakeCommands commands = new FakeCommands(); + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); + RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog); + + executor.execute(descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8))); + + assertThat(commands.evalShaCalls).hasValue(1); + assertThat(commands.evalCalls).hasValue(0); + } + + @Test + void rejectsDescriptorsOutsideItsClosedCatalogBeforeExecutingAnything() { + FakeCommands commands = new FakeCommands(); + RedisProgramCatalog ownedCatalog = RedisProgramCatalog.foundation(); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(ownedCatalog, commands); + RedisProgramDescriptor foreignDescriptor = + singleArgumentDescriptor(RedisProgramCatalog.foundation()); + + assertThatThrownBy( + () -> + executor.execute( + foreignDescriptor, + List.of("key".getBytes(UTF_8)), + List.of("owner".getBytes(UTF_8)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not owned"); + assertThat(commands.evalShaCalls).hasValue(0); + assertThat(commands.evalCalls).hasValue(0); + } + + @Test + void rejectsStatusesOutsideTheCompiledProgramContract() { + FakeCommands commands = new FakeCommands(); + commands.result = "UNDECLARED"; + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); + + assertThatThrownBy( + () -> + executor.execute( + singleArgumentDescriptor(catalog), + List.of("key".getBytes(UTF_8)), + List.of("owner".getBytes(UTF_8)))) + .isInstanceOf(RedisProgramCompatibilityException.class) + .hasMessageContaining("UNDECLARED"); + } + + private static RedisProgramDescriptor singleArgumentDescriptor(RedisProgramCatalog catalog) { + return catalog.descriptors().stream() + .filter(descriptor -> descriptor.keyCount() == 1 && descriptor.argumentCount() == 1) + .findFirst() + .orElseThrow(); + } + + private static final class FakeCommands implements RedisBinaryCommands { + + private final AtomicInteger evalShaCalls = new AtomicInteger(); + private final AtomicInteger evalCalls = new AtomicInteger(); + private boolean noScript; + private String result = "DELETED"; + + @Override + public byte[] get(byte[] key) { + return null; + } + + @Override + public void set(byte[] key, byte[] value, Duration timeToLive) {} + + @Override + public long delete(byte[] key) { + return 0; + } + + @Override + public byte[] evalSha(String sha1, List keys, List arguments) { + evalShaCalls.incrementAndGet(); + if (noScript) { + throw new RedisNoScriptException(); + } + return result.getBytes(UTF_8); + } + + @Override + public byte[] eval(byte[] script, List keys, List arguments) { + evalCalls.incrementAndGet(); + return result.getBytes(UTF_8); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java new file mode 100644 index 0000000..283425a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisProgramCatalogTest { + + @Test + void loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature() { + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + + assertThat(catalog.descriptors()).hasSize(3); + assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).keyCount()).isEqualTo(1); + assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).argumentCount()).isEqualTo(1); + assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_EXPIRE).argumentCount()).isEqualTo(2); + assertThat(catalog.descriptor(RedisProgramId.SET_IF_ABSENT_WITH_TTL).argumentCount()) + .isEqualTo(3); + + catalog + .descriptors() + .forEach( + descriptor -> { + assertThat(descriptor.sha256()).matches("[0-9a-f]{64}"); + assertThat(descriptor.scriptBytes()).isNotEmpty(); + assertThat(new String(descriptor.scriptBytes(), StandardCharsets.UTF_8)) + .contains("redis.call"); + }); + } + + @Test + void returnsDefensiveScriptCopies() { + RedisProgramDescriptor descriptor = + RedisProgramCatalog.foundation().descriptor(RedisProgramId.COMPARE_AND_DELETE); + byte[] first = descriptor.scriptBytes(); + first[0] = 0; + + assertThat(descriptor.scriptBytes()[0]).isNotZero(); + } + + @Test + void machineReadableManifestMatchesTheCompiledCatalog() throws IOException { + String manifest; + try (InputStream input = + RedisProgramCatalogTest.class + .getClassLoader() + .getResourceAsStream("redis/program-set.json")) { + assertThat(input).isNotNull(); + manifest = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(JsonPath.read(manifest, "$.readiness")).isEqualTo("R0"); + List> programs = JsonPath.read(manifest, "$.programs"); + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + assertThat(programs).hasSameSizeAs(catalog.descriptors()); + programs.forEach( + program -> { + RedisProgramId id = + catalog.descriptors().stream() + .map(RedisProgramDescriptor::id) + .filter(candidate -> candidate.externalId().equals(program.get("id"))) + .findFirst() + .orElseThrow(); + assertThat(program.get("sha256")).isEqualTo(catalog.descriptor(id).sha256()); + assertThat(Set.copyOf((List) program.get("statuses"))) + .isEqualTo(catalog.descriptor(id).statuses()); + }); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java new file mode 100644 index 0000000..cbc4c2c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class RedisRuntimeSettingsTest { + + @Test + void validatesFiniteTimeoutTtlPortAndStableHmacSecret() { + RedisRuntimeSettings settings = + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + 6379, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024); + + assertThat(settings.port()).isEqualTo(6379); + assertThat(settings.hmacSecret()).hasSize(32); + assertThat(settings.positiveTtl()).isEqualTo(Duration.ofMinutes(5)); + assertThat(settings.maximumQueuedCommands()).isEqualTo(8); + assertThat(settings.maximumInFlightBytes()).isEqualTo(16_777_216); + } + + @Test + void enabledRuntimeRejectsShortOrMissingHmacSecret() { + RedisRuntimeSettings settings = + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + 6379, + "", + "c2hvcnQ=", + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024); + + assertThatThrownBy(settings::hmacSecret) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("HMAC"); + } + + @Test + void enabledManagedRuntimeRejectsAMissingHostInsteadOfSilentlyUsingLocalhost() { + assertThatThrownBy( + () -> + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + " ", + 6379, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("host"); + } + + @Test + void rejectsUnboundedDurationsAndInvalidPort() { + assertThatThrownBy( + () -> + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + -1, + "", + "", + Duration.ZERO, + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsAnUnboundedCommandQueue() { + assertThatThrownBy( + () -> + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + 6379, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1024, + 4097, + 16_777_216)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("queued"); + } + + @Test + void rejectsByteCapacityThatCannotHoldOneMaximumValue() { + assertThatThrownBy( + () -> + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + 6379, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1_048_576, + 16, + 1_048_576)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("in-flight bytes"); + } + + @Test + void rejectsQueueAndValueBoundsWhoseWorstRetainedPayloadExceedsTheByteBudget() { + assertThatThrownBy( + () -> + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + 6379, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "worklog", + 1_048_576, + 64, + 16_777_216)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("queued-command count"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java new file mode 100644 index 0000000..5fa31b1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java @@ -0,0 +1,241 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordIntent; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RedisStringCacheRegionTest { + + private FakeCommands commands; + private RedisStringCacheRegion region; + + @BeforeEach + void setUp() { + commands = new FakeCommands(); + region = + new RedisStringCacheRegion( + new RedisCacheRegionPolicy( + new RedisKeyNamespace( + "ca-skeleton", "test", "cache", "worklog", 1, 1, "entry", 512), + new byte[32], + Duration.ofMinutes(5), + Duration.ofSeconds(30), + 1024), + commands); + } + + @Test + void recordsAndReadsPositiveEntryWithBoundedTtl() { + CacheRecordOutcome outcome = + region.record( + "tenant-1:work-1", + "cached-value", + new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + + assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED); + assertThat(commands.lastTtl).isEqualTo(Duration.ofMinutes(5)); + assertThat(new String(commands.lastKey, UTF_8)).doesNotContain("tenant-1"); + assertThat(region.lookup("tenant-1:work-1")) + .isEqualTo( + new CacheLookup.Hit<>("cached-value", CacheLookup.Freshness.FRESH, "revision-1")); + } + + @Test + void recordsAndReadsAuthoritativeNegativeEntryWithShorterTtl() { + CacheRecordOutcome outcome = + region.recordAbsent( + "tenant-1:missing", + AuthoritativeAbsence.NOT_FOUND, + new CacheRecordMetadata("revision-2", CacheRecordIntent.UPSERT)); + + assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED); + assertThat(commands.lastTtl).isEqualTo(Duration.ofSeconds(30)); + assertThat(region.lookup("tenant-1:missing")) + .isEqualTo(new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND)); + } + + @Test + void distinguishesMissIncompatibleEnvelopeAndProviderFailure() { + assertThat(region.lookup("absent")) + .isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + + commands.value = new byte[] {0, 1, 2}; + assertThat(region.lookup("invalid")) + .isEqualTo( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + + commands.failure = new IllegalStateException("connection unavailable"); + assertThatThrownBy(() -> region.lookup("programming-error")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connection unavailable"); + + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "connection unavailable", + null); + assertThat(region.lookup("unavailable")) + .isEqualTo( + new CacheLookup.Unavailable<>( + CacheLookup.UnavailabilityReason.UNAVAILABLE, + CacheLookup.OperationCertainty.NOT_APPLIED)); + } + + @Test + void refusesOpaqueNewerRevisionIntentAndMapsMutationCertainty() { + CacheRecordOutcome rejected = + region.record( + "key", + "value", + new CacheRecordMetadata( + "opaque-revision", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER)); + + assertThat(rejected).isEqualTo(CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY); + + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "timeout", + null); + assertThat( + region.record( + "key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.INDETERMINATE); + assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INDETERMINATE); + } + + @Test + void mapsKnownPreSendMutationFailureToDegradedUnavailable() { + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "disconnected", + null); + + assertThat( + region.record( + "key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.DEGRADED_UNAVAILABLE); + assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.DEGRADED_UNAVAILABLE); + } + + @Test + void rejectsInvalidRevisionFutureVersionAndBitCorruptionThroughTypedSchemaResults() { + commands.value = rawEnvelope((byte) 1, "r".repeat(129), "value"); + assertThat(region.lookup("invalid-revision")) + .isEqualTo( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + + commands.value = rawEnvelope((byte) 2, "revision-1", "value"); + assertThat(region.lookup("future")) + .isEqualTo( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.FUTURE_VERSION, CacheLookup.SchemaPolicy.FAIL_FAST)); + + commands.value = RedisCacheEnvelopeCodec.positive("value", "revision-1", 1024); + commands.value[commands.value.length - 33] ^= 1; + assertThat(region.lookup("corrupt")) + .isEqualTo( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + } + + @Test + void invalidatesExistingAndMissingEntriesSeparately() { + region.record("key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + + assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.ALREADY_ABSENT); + } + + private static final class FakeCommands implements RedisBinaryCommands { + + private byte[] lastKey; + private byte[] value; + private Duration lastTtl; + private RuntimeException failure; + + @Override + public byte[] get(byte[] key) { + failIfConfigured(); + return value == null ? null : value.clone(); + } + + @Override + public void set(byte[] key, byte[] value, Duration timeToLive) { + failIfConfigured(); + lastKey = key.clone(); + this.value = value.clone(); + lastTtl = timeToLive; + } + + @Override + public long delete(byte[] key) { + failIfConfigured(); + if (value == null) { + return 0; + } + value = null; + return 1; + } + + @Override + public byte[] evalSha(String sha1, List keys, List arguments) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] eval(byte[] script, List keys, List arguments) { + throw new UnsupportedOperationException(); + } + + private void failIfConfigured() { + if (failure != null) { + throw failure; + } + } + } + + private static byte[] rawEnvelope(byte version, String revision, String value) { + byte[] revisionBytes = revision.getBytes(UTF_8); + byte[] valueBytes = value.getBytes(UTF_8); + byte[] content = + ByteBuffer.allocate(12 + revisionBytes.length + valueBytes.length) + .putInt(0x43414348) + .put(version) + .put((byte) 1) + .putShort((short) revisionBytes.length) + .putInt(valueBytes.length) + .put(revisionBytes) + .put(valueBytes) + .array(); + byte[] digest; + try { + digest = java.security.MessageDigest.getInstance("SHA-256").digest(content); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + return ByteBuffer.allocate(content.length + digest.length).put(content).put(digest).array(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java new file mode 100644 index 0000000..c7b5f7e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.cache.redis.key; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisKeyBuilderTest { + + private static final byte[] HMAC_SECRET = + "test-only-hmac-material-with-at-least-32-bytes".getBytes(UTF_8); + + @Test + void buildsNamespacedKeyWithoutLeakingSensitiveComponents() { + RedisKeyNamespace namespace = + new RedisKeyNamespace("worklog-api", "prod", "cache", "summary", 2, 1, "entry", 256); + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + 2, + HMAC_SECRET, + List.of("tenant@example.com".getBytes(UTF_8), "worklog-42".getBytes(UTF_8))); + + String key = RedisKeyBuilder.build(namespace, digest); + + assertThat(key) + .startsWith("ca:worklog-api:prod:cache:summary:hv2:kv1:{") + .endsWith(":entry") + .doesNotContain("tenant@example.com") + .doesNotContain("worklog-42"); + assertThat(key.chars().filter(character -> character == '{').count()).isEqualTo(1); + assertThat(key.chars().filter(character -> character == '}').count()).isEqualTo(1); + assertThat(key.getBytes(UTF_8).length).isLessThanOrEqualTo(256); + } + + @Test + void lengthPrefixedDigestPreventsComponentBoundaryAmbiguity() { + RedisKeyDigest first = + RedisKeyDigest.sensitive( + 1, HMAC_SECRET, List.of("ab".getBytes(UTF_8), "c".getBytes(UTF_8))); + RedisKeyDigest second = + RedisKeyDigest.sensitive( + 1, HMAC_SECRET, List.of("a".getBytes(UTF_8), "bc".getBytes(UTF_8))); + + assertThat(first.resourceDigest()).isNotEqualTo(second.resourceDigest()); + } + + @Test + void rejectsDigestVersionMismatchAndOversizedPhysicalKey() { + RedisKeyDigest digest = RedisKeyDigest.opaque(1, List.of("id".getBytes(UTF_8))); + + assertThatThrownBy( + () -> + RedisKeyBuilder.build( + new RedisKeyNamespace( + "worklog-api", "prod", "cache", "summary", 2, 1, "entry", 256), + digest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("version"); + + assertThatThrownBy( + () -> + RedisKeyBuilder.build( + new RedisKeyNamespace( + "worklog-api", "prod", "cache", "summary", 1, 1, "entry", 32), + digest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bytes"); + } +} diff --git a/src/adapter/outbound/fileserver/CLAUDE.md b/src/adapter/outbound/fileserver/CLAUDE.md index 8129cb8..45da13a 100644 --- a/src/adapter/outbound/fileserver/CLAUDE.md +++ b/src/adapter/outbound/fileserver/CLAUDE.md @@ -4,27 +4,29 @@ - Module ID: `adapter-outbound-fileserver` - Gradle path: `:adapter:outbound:fileserver` -- Focused test: `./gradlew :adapter:outbound:fileserver:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:fileserver:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) adapter implementing -`dev.caskeleton.application.fileexport.FileExportPort` (application-core). Design rationale lives in -[README.md](README.md). +`dev.caskeleton.application.filepublication.FilePublicationPort` (application-core). The legacy +`FileExportPort` remains temporarily for compatibility. Design rationale lives in [README.md](README.md). ## Responsibility -- Export tabular data as CSV files behind `FileExportPort`, written under - `ca-skeleton.fileserver.base-directory` (stand-in for NFS/SFTP). Single implementation - (`FilesystemCsvExportAdapter`); pure JDK filesystem IO, no external service. -- Opt-in: `FileExportConfig` gates the single `FileExportPort` bean with - `@ConditionalOnProperty(ca-skeleton.fileserver.enabled=true)`, default off. The adapter is a plain - class; the config assembles it as a bean. +- Publish typed tabular data through a bounded producer/sink contract behind + `FilePublicationPort`. +- Own CSV encoding, schema validation, formula policy, staging, checksum/counts, file force, and + local exclusive-publication semantics. +- Return opaque references and explicit publication/durability guarantees; do not expose paths. +- Opt-in: `FileExportConfig` gates publication with + `ca-skeleton.fileserver.enabled=true`; the legacy bean additionally requires + `ca-skeleton.fileserver.legacy-enabled=true` and a separate root. Both default off. ## Allowed - Project deps: `:application-core`, `:shared-contract` — SSOT is the - `adapter-outbound-fileserver` entry in `.harness/project/modules.yaml`; `src/build.gradle` + `adapter-outbound-fileserver` entry in `src/config/architecture/modules.json`; `src/build.gradle` enforces it. No `:domain-core`, no sibling adapters. - External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor` @@ -34,14 +36,18 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad - Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio` (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). -- Leaking a framework/domain type across `FileExportPort` — the port takes/returns only `String` / - `List` / `List>` / `ExportedFile`. +- Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`. +- Advertising local R1 as crash-recoverable R2. Durable operation journal, reconciliation, SFTP, + and NFS/HA evidence are not fully implemented. The local journal only supports single-node + terminal restoration and sealed-artifact resume; it is not cross-node fencing or R2 evidence. +- Adding a second production provider without an explicit selector and startup ambiguity tests. - Fully-qualified inline type references; more than one public top-level type per file. ## Tests -`FilesystemCsvExportAdapterTest` (temp-dir CSV write/verify: header + rows, RFC-4180 escaping, -null-field, overwrite, path-traversal + blank-name rejection). +`FilePublicationContractTest`, `LocalFilePublicationAdapterTest`, +`LocalPublicationJournalTest`, `LocalFilePublicationRecoveryTest`, `FilePublicationConfigTest`, and +the legacy `FilesystemCsvExportAdapterTest`. ```bash cd src diff --git a/src/adapter/outbound/fileserver/README.md b/src/adapter/outbound/fileserver/README.md index e79a49b..c4381c1 100644 --- a/src/adapter/outbound/fileserver/README.md +++ b/src/adapter/outbound/fileserver/README.md @@ -1,62 +1,92 @@ # adapter:outbound:fileserver — design-decision reference -File-server export outbound (driven) adapter. Package root: -`dev.caskeleton.adapter.outbound.fileserver`. Implements the `application-core` port -`dev.caskeleton.application.fileexport.FileExportPort` behind an opt-in `@ConditionalOnProperty` -selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient / -objectstorage). +File-server publication outbound (driven) adapter. Package root: +`dev.caskeleton.adapter.outbound.fileserver`. It implements the framework-free +`application-core` `FilePublicationPort` and temporarily retains the legacy `FileExportPort`. +Publication and the legacy compatibility port have separate opt-in selectors. The allowed/forbidden dependency policy is owned by `src/build.gradle`'s `allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in [CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code comments. -## Module overview +## Implemented capability -An **opt-in** file-export adapter placed behind an application-core port. A single -`FilesystemCsvExportAdapter` writes CSV files under `ca-skeleton.fileserver.base-directory` — a -stand-in for an NFS mount, shared file server, or SFTP drop. There is no external service and no -external dependency (pure JDK filesystem IO), so the local profile just works and the lockfile only -pins the shared Spring Boot / tooling graph. +`LocalFilePublicationAdapter` is a local-filesystem R1 provider. The application supplies a typed +schema and streams rows once through a producer/sink callback. The adapter encodes each row without +materializing the whole export, enforces row/encoded-byte/per-cell limits, applies the configured +spreadsheet-formula policy, computes SHA-256 and counts, forces the staged file, and publishes it +with an exclusive atomic hard-link create. After publication it forces both staging and final +directories before recording the terminal journal. Its receipt contains an opaque reference rather than a +server path. A private, forced operation journal records request fingerprints and `WRITING`, `SEALED`, +and `PUBLISHED` state. On a single local filesystem, a restarted adapter can restore a verified +terminal receipt or finish a verified sealed staging artifact without invoking the producer again. +A sealed journal plus a verified final target reconstructs the only supported hard-link protocol +as `UNIQUE_ATOMIC_CREATE` after re-forcing the final directory. +Corrupt/unreadable operation state is exposed only as provider-neutral +`PUBLISH_INDETERMINATE`, never as an adapter-internal exception. +The private control directory and journal shards reject symbolic links before read/write so a +pre-existing internal link cannot redirect journal bytes outside the configured base. +Once a `SEALED` record exists, publish conflicts and unsupported atomic publication preserve the +verified staging artifact for explicit retry/reconciliation instead of deleting the only recovery +evidence. +Operation-scoped JVM and OS file locks serialize cooperating callers on the same local filesystem. -Selector: `ca-skeleton.fileserver.enabled=true` (default `false`). Unlike objectstorage there is a -single implementation, so no backend switch is needed; the `enabled` flag keeps the module from -activating unexpectedly when merely present on the classpath. `FileExportConfig` gates the single -`FileExportPort` bean on that flag. +Selector: `ca-skeleton.fileserver.enabled=true` (default `false`) enables only the new +`FilePublicationPort`. The overwrite-capable compatibility port additionally requires +`ca-skeleton.fileserver.legacy-enabled=true` and writes under its own legacy root. This module is not +currently a default `app-bootstrap` dependency, so a consuming application must intentionally add +the leaf as well as enable it. -## The port contract (framework/domain-neutral) +## Publication contract -`FileExportPort` is a minimal, domain-neutral surface: +The current contract is: -- `ExportedFile exportCsv(String fileName, List header, List> rows)`. +- `FilePublishReceipt publish(FilePublishRequest, TabularRowProducer)`. -The caller supplies a bare file name, an optional header row, and the data rows as lists of -already-stringified field values. The adapter owns file placement, RFC-4180 escaping, and byte -encoding, and returns an `ExportedFile` receipt (`fileName`, absolute `path`, `byteSize`, -`rowCount`). No framework or domain type crosses the port — the application layer stays decoupled -from the CSV format and the destination filesystem. A fork that needs a real domain export maps its -rows to `List>` at the call site (or adds a typed convenience method in its own layer). +The request uses `FileDestinationId`, `FilePublishOperationId`, `LogicalFileName`, +`SourceRevision`, and `ExportSchema`; it has no `Path`, `File`, Spring, stream, or provider type. +`TabularCell` preserves value types until encoding. The producer writes rows to a bounded sink and +can call `checkpoint()` for cooperative interruption checks. -## CSV escaping +`FileExportPort.exportCsv(...)` remains for compatibility only. It materializes all rows, writes +directly to a separate final-path root, permits overwrite, accepts only a bare file name, and +returns an absolute path. It must not be used as +R2 durability or cluster-safety evidence. -Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage return, or -line feed is wrapped in double-quotes with embedded quotes doubled. A `null` field is written as an -empty field. Rows are separated by `\n` and the file is UTF-8 encoded. Overwriting an existing file -at the same name replaces it. +## Settings -## IO-failure handling +- `ca-skeleton.fileserver.enabled=false` +- `ca-skeleton.fileserver.legacy-enabled=false` +- `ca-skeleton.fileserver.base-directory=./.data/fileserver` +- `ca-skeleton.fileserver.legacy-base-directory=./.data/fileserver-legacy` +- `ca-skeleton.fileserver.destination-id=local-export` +- `ca-skeleton.fileserver.maximum-rows=1000000` +- `ca-skeleton.fileserver.maximum-encoded-bytes=1073741824` -Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException` -(`dependencyName="fileserver"`) so a fork's web error handler classifies them uniformly with the -other outbound dependencies. A blank file name or one that escapes the base directory (path -traversal) is `IllegalArgumentException` (a caller bug, not a dependency failure) — the adapter -normalises the resolved path and checks it still starts with the base directory. +The provider always fails closed if the filesystem cannot supply exclusive hard-link creation. +There is no copy-to-final or overwrite-capable rename fallback. + +## Guarantee boundary + +This remains local R1, not Fileserver R2. It now provides single-node request fingerprinting, +forced operation-journal replacement, terminal receipt restoration, and bounded sealed-artifact +reconciliation. It does not yet provide cross-node fencing, exhaustive crash-point qualification, +reference/manifest indexes, background reconciliation/reaping, SFTP, NFS mount identity, +multi-node cleanup, or quota reservation. See +`docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` for the remaining +phases. ## Tests -- `FilesystemCsvExportAdapterTest` — `@TempDir` write/verify: header + rows, CSV escaping of a field - containing a comma / quote / newline, null-field handling, header-only and headerless exports, - overwrite, and path-traversal + blank-name rejection. +- `FilePublicationContractTest`: framework-free values and invalid contract inputs. +- `LocalFilePublicationAdapterTest`: streaming, limits, type checks, formula mitigation, receipt, + publication, and staging cleanup. +- `LocalPublicationJournalTest`: canonical request fingerprint and strict journal integrity. +- `LocalFilePublicationRecoveryTest`: restart receipt restoration, sealed resume, conflict, and + artifact-integrity handling. +- `FilePublicationConfigTest`: opt-in binding and both port beans. +- `FilesystemCsvExportAdapterTest`: legacy compatibility path. ```bash cd src diff --git a/src/adapter/outbound/fileserver/build.gradle b/src/adapter/outbound/fileserver/build.gradle index 6fe5159..888200c 100644 --- a/src/adapter/outbound/fileserver/build.gradle +++ b/src/adapter/outbound/fileserver/build.gradle @@ -1,15 +1,17 @@ // Driven adapter: file server / filesystem exports behind application-core's FileExportPort. Writes // delimited (CSV) files to a configured base directory — a stand-in for an NFS mount, shared file -// server, or SFTP drop. Pure JDK filesystem IO, so there are NO external dependencies: the lockfile -// only pins the shared Spring Boot / tooling graph. Opt-in via @ConditionalOnProperty -// (ca-skeleton.fileserver.enabled), off by default so the module never activates unexpectedly. +// server, or SFTP drop. Its IO path uses only the JDK; Spring Boot autoconfigure and the SLF4J API +// provide conditional composition and diagnostics without an external file-client SDK. Opt-in via +// @ConditionalOnProperty (ca-skeleton.fileserver.enabled), off by default so the module never +// activates unexpectedly. description = 'Outbound adapter: file server exports (filesystem/CSV)' dependencies { implementation project(':application-core') implementation project(':shared-contract') - implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.slf4j:slf4j-api' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } diff --git a/src/adapter/outbound/fileserver/gradle.lockfile b/src/adapter/outbound/fileserver/gradle.lockfile index ea5fec2..5370d98 100644 --- a/src/adapter/outbound/fileserver/gradle.lockfile +++ b/src/adapter/outbound/fileserver/gradle.lockfile @@ -1,23 +1,23 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -44,7 +44,7 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -60,9 +60,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -95,10 +95,10 @@ org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs @@ -108,7 +108,7 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -121,13 +121,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -145,7 +145,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java index 082530f..8c60280 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java @@ -1,6 +1,11 @@ package dev.caskeleton.adapter.outbound.fileserver; import dev.caskeleton.application.fileexport.FileExportPort; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationPort; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -18,8 +23,47 @@ import org.springframework.context.annotation.Configuration; public class FileExportConfig { @Bean - @ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true") + @ConditionalOnProperty( + prefix = "ca-skeleton.fileserver", + name = {"enabled", "legacy-enabled"}, + havingValue = "true") public FileExportPort filesystemCsvExportPort(FileExportProperties properties) { - return new FilesystemCsvExportAdapter(properties.getBaseDirectory()); + Path publicationRoot = configuredRoot(properties.getBaseDirectory(), "base-directory"); + Path legacyRoot = configuredRoot(properties.getLegacyBaseDirectory(), "legacy-base-directory"); + Path canonicalPublicationRoot = canonicalDirectory(publicationRoot); + Path canonicalLegacyRoot = canonicalDirectory(legacyRoot); + if (canonicalPublicationRoot.startsWith(canonicalLegacyRoot) + || canonicalLegacyRoot.startsWith(canonicalPublicationRoot)) { + throw new IllegalStateException("fileserver publication and legacy roots must not overlap"); + } + return new FilesystemCsvExportAdapter(legacyRoot.toString()); + } + + @Bean + @ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true") + public FilePublicationPort localFilePublicationPort(FileExportProperties properties) { + return new LocalFilePublicationAdapter( + new LocalFilePublicationPolicy( + new FileDestinationId(properties.getDestinationId()), + configuredRoot(properties.getBaseDirectory(), "base-directory"), + properties.getMaximumRows(), + properties.getMaximumEncodedBytes())); + } + + private static Path configuredRoot(String value, String property) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException( + "ca-skeleton.fileserver." + property + " must be non-blank"); + } + return Path.of(value).toAbsolutePath().normalize(); + } + + private static Path canonicalDirectory(Path root) { + try { + Files.createDirectories(root); + return root.toRealPath(); + } catch (IOException exception) { + throw new IllegalStateException("fileserver root cannot be canonicalized", exception); + } } } diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java index 69b9972..a77b724 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java @@ -16,9 +16,24 @@ public class FileExportProperties { */ private boolean enabled = false; + /** Whether to expose the overwrite-capable legacy port. Always requires {@link #enabled}. */ + private boolean legacyEnabled = false; + /** Base directory that export files are written under (stand-in for an NFS/SFTP drop). */ private String baseDirectory = "./.data/fileserver"; + /** Separate root for the legacy overwrite-capable port; never shares publication control data. */ + private String legacyBaseDirectory = "./.data/fileserver-legacy"; + + /** Logical destination identifier exposed to application-core; never a path or host. */ + private String destinationId = "local-export"; + + /** Hard upper bound for data rows accepted by one streaming publication. */ + private long maximumRows = 1_000_000; + + /** Hard upper bound for encoded bytes, including the CSV header. */ + private long maximumEncodedBytes = 1_073_741_824; + public boolean isEnabled() { return enabled; } @@ -27,6 +42,14 @@ public class FileExportProperties { this.enabled = enabled; } + public boolean isLegacyEnabled() { + return legacyEnabled; + } + + public void setLegacyEnabled(boolean legacyEnabled) { + this.legacyEnabled = legacyEnabled; + } + public String getBaseDirectory() { return baseDirectory; } @@ -34,4 +57,36 @@ public class FileExportProperties { public void setBaseDirectory(String baseDirectory) { this.baseDirectory = baseDirectory; } + + public String getLegacyBaseDirectory() { + return legacyBaseDirectory; + } + + public void setLegacyBaseDirectory(String legacyBaseDirectory) { + this.legacyBaseDirectory = legacyBaseDirectory; + } + + public String getDestinationId() { + return destinationId; + } + + public void setDestinationId(String destinationId) { + this.destinationId = destinationId; + } + + public long getMaximumRows() { + return maximumRows; + } + + public void setMaximumRows(long maximumRows) { + this.maximumRows = maximumRows; + } + + public long getMaximumEncodedBytes() { + return maximumEncodedBytes; + } + + public void setMaximumEncodedBytes(long maximumEncodedBytes) { + this.maximumEncodedBytes = maximumEncodedBytes; + } } diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java new file mode 100644 index 0000000..a5e975b --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** Canonical fingerprint for retry/recovery intent comparison. */ +final class FilePublishRequestFingerprint { + + private static final HexFormat HEX = HexFormat.of(); + + private FilePublishRequestFingerprint() {} + + static String calculate(FilePublishRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + MessageDigest digest = sha256(); + update(digest, request.operationId().value()); + update(digest, request.destinationId().value()); + update(digest, request.logicalFileName().value()); + update(digest, request.sourceRevision().value()); + update(digest, request.formatProfileId()); + update(digest, request.schema().schemaId()); + update(digest, request.schema().version()); + update(digest, request.schema().columns().size()); + for (Column column : request.schema().columns()) { + update(digest, column.name()); + update(digest, column.cellType().name()); + update(digest, column.nullable() ? 1 : 0); + update(digest, column.formulaPolicy().name()); + update(digest, column.maximumUtf8Bytes()); + } + return HEX.formatHex(digest.digest()); + } + + private static void update(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + digest.update(bytes); + } + + private static void update(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java index 68202ef..b059626 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java @@ -97,7 +97,14 @@ public class FilesystemCsvExportAdapter implements FileExportPort { if (fileName == null || fileName.isBlank()) { throw new IllegalArgumentException("fileName must be non-null and non-blank"); } - Path resolved = baseDir.resolve(fileName).normalize(); + Path relative = Path.of(fileName); + if (relative.isAbsolute() + || relative.getNameCount() != 1 + || ".".equals(fileName) + || "..".equals(fileName)) { + throw new IllegalArgumentException("fileName must be a bare file name"); + } + Path resolved = baseDir.resolve(relative).normalize(); if (!resolved.startsWith(baseDir) || resolved.equals(baseDir)) { throw new IllegalArgumentException("illegal export file name (path traversal): " + fileName); } diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java new file mode 100644 index 0000000..471fe5a --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java @@ -0,0 +1,437 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublicationPort; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.FileVersion; +import dev.caskeleton.application.filepublication.PublishedFileReference; +import dev.caskeleton.application.filepublication.TabularRowProducer; +import dev.caskeleton.application.filepublication.TabularRowSink; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Local staged CSV provider with single-node operation recovery. It does not claim cross-node + * fencing, background reconciliation, or R2 qualification. + */ +public final class LocalFilePublicationAdapter implements FilePublicationPort { + + private static final String FORMAT_PROFILE = "csv-rfc4180-v1"; + private static final HexFormat HEX = HexFormat.of(); + + private final LocalFilePublicationPolicy policy; + private final Path baseDirectory; + private final Path stagingDirectory; + private final LocalPublicationJournal journal; + private final AtomicLinkPublisher atomicLinkPublisher; + + public LocalFilePublicationAdapter(LocalFilePublicationPolicy policy) { + this(policy, (target, staging) -> Files.createLink(target, staging)); + } + + LocalFilePublicationAdapter( + LocalFilePublicationPolicy policy, AtomicLinkPublisher atomicLinkPublisher) { + this.policy = Objects.requireNonNull(policy, "policy must be non-null"); + this.atomicLinkPublisher = + Objects.requireNonNull(atomicLinkPublisher, "atomicLinkPublisher must be non-null"); + this.baseDirectory = policy.baseDirectory(); + this.stagingDirectory = baseDirectory.resolve(".staging"); + initializeDirectories(); + try { + this.journal = new LocalPublicationJournal(baseDirectory); + } catch (LocalPublicationJournalException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, + "file publication control journal is unavailable", + exception); + } + } + + @Override + public FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer) { + validateRequest(request); + Objects.requireNonNull(producer, "producer must be non-null"); + LocalPublicationJournal.OperationLock operationLock; + try { + operationLock = journal.acquire(request.operationId().value()); + } catch (LocalPublicationJournalException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, + "file publication operation lock is unavailable", + exception); + } + try (operationLock) { + return publishLocked(request, producer); + } catch (LocalPublicationJournalException exception) { + throw indeterminate(exception); + } + } + + private FilePublishReceipt publishLocked( + FilePublishRequest request, TabularRowProducer producer) { + String operationToken = operationToken(request.operationId().value()); + String publishedFileName = request.logicalFileName().value() + "--" + operationToken + ".csv"; + Path staging = stagingDirectory.resolve("." + operationToken + ".part"); + Path target = baseDirectory.resolve(publishedFileName); + String requestFingerprint = FilePublishRequestFingerprint.calculate(request); + Optional prior; + try { + prior = journal.find(request.operationId().value()); + } catch (LocalPublicationJournalException exception) { + throw indeterminate(exception); + } + if (prior.isPresent()) { + return recover(prior.get(), request, requestFingerprint, staging, target); + } + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new FilePublicationException( + FilePublicationException.Reason.CONFLICT, + "file publication target already exists for operation"); + } + + LocalPublicationJournalRecord writing = + LocalPublicationJournalRecord.writing( + request.operationId().value(), + requestFingerprint, + publishedFileName, + staging.getFileName().toString()); + storeBeforeCommit(writing); + StreamingCsvEncoder.Stats stats; + try (FileChannel channel = + FileChannel.open( + staging, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS); + OutputStream output = Channels.newOutputStream(channel)) { + restrictPermissions(staging); + StreamingCsvEncoder encoder = + new StreamingCsvEncoder( + request.schema(), + output, + sha256(), + policy.maximumRows(), + policy.maximumEncodedBytes()); + encoder.writeHeader(); + TabularRowSink sink = + new TabularRowSink() { + @Override + public void write(dev.caskeleton.application.filepublication.TabularRow row) { + encoder.write(row); + } + + @Override + public void checkpoint() { + encoder.checkpoint(); + } + }; + producer.produce(sink); + encoder.checkpoint(); + output.flush(); + channel.force(true); + stats = encoder.finish(); + } catch (RuntimeException exception) { + deleteStaging(staging, exception); + journal.delete(request.operationId().value(), exception); + throw exception; + } catch (FileAlreadyExistsException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.CONFLICT, + "file publication operation is already staging", + exception); + } catch (IOException exception) { + deleteStaging(staging, exception); + journal.delete(request.operationId().value(), exception); + throw new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, + "file publication staging failed", + exception); + } + + LocalPublicationJournalRecord sealed = + LocalPublicationJournalRecord.sealed( + request.operationId().value(), + requestFingerprint, + publishedFileName, + staging.getFileName().toString(), + stats.byteSize(), + stats.rowCount(), + request.schema().columns().size(), + stats.sha256(), + stats.formulaMitigatedCount()); + storeBeforeCommit(sealed); + PublicationGuarantee guarantee = publishStaging(staging, target); + verifyArtifact(target, sealed); + Instant publishedAt = Instant.now(); + storeTerminal(sealed.published(publishedAt, guarantee.name())); + return receipt(request, sealed, guarantee, publishedAt); + } + + private FilePublishReceipt recover( + LocalPublicationJournalRecord record, + FilePublishRequest request, + String requestFingerprint, + Path staging, + Path target) { + if (!record.requestFingerprint().equals(requestFingerprint)) { + throw new FilePublicationException( + FilePublicationException.Reason.CONFLICT, + "file publication operation was already used for a different request"); + } + if (!record.stageFileName().equals(staging.getFileName().toString()) + || !record.publishedFileName().equals(target.getFileName().toString())) { + throw indeterminate(new IOException("journal locator mismatch")); + } + if (record.state() == LocalPublicationJournalRecord.State.WRITING) { + throw indeterminate(new IOException("publication interrupted before sealing")); + } + PublicationGuarantee guarantee; + if (record.state() == LocalPublicationJournalRecord.State.PUBLISHED) { + verifyArtifact(target, record); + guarantee = publicationGuarantee(record.publicationGuarantee()); + return receipt(request, record, guarantee, Instant.parse(record.publishedAt())); + } + + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + verifyArtifact(target, record); + try { + forceDirectory(baseDirectory); + } catch (IOException exception) { + throw indeterminate(exception); + } + guarantee = PublicationGuarantee.UNIQUE_ATOMIC_CREATE; + } else if (Files.exists(staging, LinkOption.NOFOLLOW_LINKS)) { + verifyArtifact(staging, record); + guarantee = publishStaging(staging, target); + verifyArtifact(target, record); + } else { + throw indeterminate(new IOException("sealed publication artifact is unavailable")); + } + Instant publishedAt = Instant.now(); + storeTerminal(record.published(publishedAt, guarantee.name())); + return receipt(request, record, guarantee, publishedAt); + } + + private FilePublishReceipt receipt( + FilePublishRequest request, + LocalPublicationJournalRecord record, + PublicationGuarantee guarantee, + Instant publishedAt) { + String operationToken = operationToken(request.operationId().value()); + return new FilePublishReceipt( + request.operationId(), + new PublishedFileReference( + "filepub:" + policy.destinationId().value() + ":" + operationToken), + request.destinationId(), + record.publishedFileName(), + new FileVersion(record.sha256()), + request.formatProfileId(), + "text/csv", + "UTF-8", + record.byteSize(), + record.rowCount(), + record.columnCount(), + record.sha256(), + publishedAt, + guarantee, + DurabilityGuarantee.PROCESS_LOCAL_SYNC, + record.formulaMitigatedCount()); + } + + private static PublicationGuarantee publicationGuarantee(String name) { + try { + return PublicationGuarantee.valueOf(name); + } catch (IllegalArgumentException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.PUBLISH_INDETERMINATE, + "file publication journal contains an unknown guarantee", + exception); + } + } + + private static void verifyArtifact(Path artifact, LocalPublicationJournalRecord record) { + try { + if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) + || Files.size(artifact) != record.byteSize() + || !digest(artifact).equals(record.sha256())) { + throw indeterminate(new IOException("publication artifact integrity mismatch")); + } + } catch (IOException exception) { + throw indeterminate(exception); + } + } + + private static String digest(Path artifact) throws IOException { + MessageDigest digest = sha256(); + try (var input = Files.newInputStream(artifact, LinkOption.NOFOLLOW_LINKS)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + } + return HEX.formatHex(digest.digest()); + } + + private void validateRequest(FilePublishRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + if (!policy.destinationId().equals(request.destinationId())) { + throw new FilePublicationException( + FilePublicationException.Reason.INVALID_REQUEST, + "file publication destination is not bound to this provider"); + } + if (!FORMAT_PROFILE.equals(request.formatProfileId())) { + throw new FilePublicationException( + FilePublicationException.Reason.INVALID_REQUEST, + "unsupported file publication format profile"); + } + } + + private PublicationGuarantee publishStaging(Path staging, Path target) { + try { + atomicLinkPublisher.publish(target, staging); + forceDirectory(baseDirectory); + Files.delete(staging); + forceDirectory(stagingDirectory); + return PublicationGuarantee.UNIQUE_ATOMIC_CREATE; + } catch (FileAlreadyExistsException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.CONFLICT, + "file publication target already exists", + exception); + } catch (IOException exception) { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw indeterminate(exception); + } + throw unavailableAtomicPublication(exception); + } catch (UnsupportedOperationException exception) { + throw unavailableAtomicPublication(exception); + } + } + + private static FilePublicationException unavailableAtomicPublication(Exception exception) { + return new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, + "exclusive atomic file publication is unavailable", + exception); + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private void initializeDirectories() { + try { + Files.createDirectories(baseDirectory); + if (Files.isSymbolicLink(baseDirectory) + || !Files.isDirectory(baseDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new FilePublicationException( + FilePublicationException.Reason.INVALID_REQUEST, + "file publication base directory must be a real directory"); + } + Files.createDirectories(stagingDirectory); + if (Files.isSymbolicLink(stagingDirectory) + || !Files.isDirectory(stagingDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new FilePublicationException( + FilePublicationException.Reason.INVALID_REQUEST, + "file publication staging directory must be a real directory"); + } + restrictPermissions(stagingDirectory); + } catch (IOException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, + "file publication directories are unavailable", + exception); + } + } + + private static void restrictPermissions(Path path) throws IOException { + try { + Files.setPosixFilePermissions( + path, + Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) + ? Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE) + : Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystems are allowed only as R1; deployment evidence owns permission claims. + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static String operationToken(String operationId) { + MessageDigest digest = sha256(); + return HEX.formatHex(digest.digest(operationId.getBytes(StandardCharsets.UTF_8))) + .substring(0, 24); + } + + private void storeBeforeCommit(LocalPublicationJournalRecord record) { + try { + journal.store(record); + } catch (LocalPublicationJournalException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, + "file publication control journal update failed before commit", + exception); + } + } + + private void storeTerminal(LocalPublicationJournalRecord record) { + try { + journal.store(record); + } catch (LocalPublicationJournalException exception) { + throw indeterminate(exception); + } + } + + private static FilePublicationException indeterminate(Throwable exception) { + return new FilePublicationException( + FilePublicationException.Reason.PUBLISH_INDETERMINATE, + "file publication commit outcome is indeterminate", + exception); + } + + private static void deleteStaging(Path staging, Throwable original) { + try { + Files.deleteIfExists(staging); + } catch (IOException cleanupFailure) { + original.addSuppressed(cleanupFailure); + } + } + + @FunctionalInterface + interface AtomicLinkPublisher { + + void publish(Path target, Path staging) throws IOException; + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java new file mode 100644 index 0000000..8b5f377 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import java.nio.file.Path; +import java.util.Objects; + +/** Immutable operational bounds for the local R1 provider. */ +public record LocalFilePublicationPolicy( + FileDestinationId destinationId, + Path baseDirectory, + long maximumRows, + long maximumEncodedBytes) { + + public LocalFilePublicationPolicy { + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + Objects.requireNonNull(baseDirectory, "baseDirectory must be non-null"); + baseDirectory = baseDirectory.toAbsolutePath().normalize(); + if (maximumRows < 1) { + throw new IllegalArgumentException("maximumRows must be >= 1"); + } + if (maximumEncodedBytes < 1) { + throw new IllegalArgumentException("maximumEncodedBytes must be >= 1"); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java new file mode 100644 index 0000000..3e8ddfe --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java @@ -0,0 +1,294 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Semaphore; + +/** Forced, atomically replaced operation journal for the local provider. */ +final class LocalPublicationJournal { + + private static final int MAXIMUM_RECORD_BYTES = 16_384; + private static final int JVM_LOCK_STRIPE_COUNT = 256; + private static final HexFormat HEX = HexFormat.of(); + private static final Semaphore[] JVM_LOCK_STRIPES = createLockStripes(); + + private final Path controlDirectory; + private final Path operationsDirectory; + + LocalPublicationJournal(Path baseDirectory) { + controlDirectory = baseDirectory.resolve(".ca-fileserver"); + operationsDirectory = controlDirectory.resolve("operations"); + initialize(); + } + + Optional find(String operationId) { + Path record = recordPath(operationId); + validateShardIfPresent(record.getParent()); + if (!Files.exists(record, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + try { + if (!Files.isRegularFile(record, LinkOption.NOFOLLOW_LINKS) + || Files.size(record) > MAXIMUM_RECORD_BYTES) { + throw new LocalPublicationJournalException("local publication journal is corrupt"); + } + LocalPublicationJournalRecord decoded = + LocalPublicationJournalCodec.decode(Files.readAllBytes(record)); + if (!decoded.operationId().equals(operationId)) { + throw new LocalPublicationJournalException("local publication journal identity mismatch"); + } + return Optional.of(decoded); + } catch (IOException exception) { + throw new LocalPublicationJournalException( + "local publication journal cannot be read", exception); + } + } + + OperationLock acquire(String operationId) { + Path lockPath = lockPath(operationId); + Semaphore jvmLock = + JVM_LOCK_STRIPES[ + Math.floorMod(lockPath.toAbsolutePath().normalize().hashCode(), JVM_LOCK_STRIPE_COUNT)]; + jvmLock.acquireUninterruptibly(); + FileChannel channel = null; + try { + createRealDirectory(lockPath.getParent()); + restrictDirectory(lockPath.getParent()); + if (Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS)) { + throw new LocalPublicationJournalException("local publication operation lock is invalid"); + } + channel = + FileChannel.open( + lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS); + restrictFile(lockPath); + return new OperationLock(channel.lock(), channel, jvmLock); + } catch (IOException | RuntimeException exception) { + closeAfterAcquireFailure(channel, exception); + jvmLock.release(); + if (exception instanceof LocalPublicationJournalException journalException) { + throw journalException; + } + throw new LocalPublicationJournalException( + "local publication operation lock is unavailable", exception); + } + } + + void store(LocalPublicationJournalRecord record) { + byte[] encoded = LocalPublicationJournalCodec.encode(record); + Path target = recordPath(record.operationId()); + Path parent = target.getParent(); + Path temporary = parent.resolve("." + target.getFileName() + "." + UUID.randomUUID() + ".tmp"); + try { + createRealDirectory(parent); + restrictDirectory(parent); + try (FileChannel channel = + FileChannel.open( + temporary, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS)) { + restrictFile(temporary); + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + Files.move( + temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + forceDirectory(parent); + } catch (AtomicMoveNotSupportedException exception) { + deleteTemporary(temporary, exception); + throw new LocalPublicationJournalException( + "atomic local publication journal update is unavailable", exception); + } catch (IOException exception) { + deleteTemporary(temporary, exception); + throw new LocalPublicationJournalException( + "local publication journal cannot be stored", exception); + } + } + + void delete(String operationId, Throwable original) { + Path target = recordPath(operationId); + try { + validateShardIfPresent(target.getParent()); + if (Files.deleteIfExists(target)) { + forceDirectory(target.getParent()); + } + } catch (IOException cleanupFailure) { + original.addSuppressed(cleanupFailure); + } + } + + Path recordPath(String operationId) { + String token = token(operationId); + return operationsDirectory.resolve(token.substring(0, 2)).resolve(token + ".json"); + } + + private Path lockPath(String operationId) { + String token = token(operationId); + return operationsDirectory.resolve(token.substring(0, 2)).resolve(token + ".lock"); + } + + private void initialize() { + try { + createRealDirectory(controlDirectory); + restrictDirectory(controlDirectory); + createRealDirectory(operationsDirectory); + restrictDirectory(operationsDirectory); + } catch (IOException exception) { + throw new LocalPublicationJournalException( + "local publication control directory is unavailable", exception); + } + } + + private static void createRealDirectory(Path directory) throws IOException { + try { + Files.createDirectory(directory); + } catch (FileAlreadyExistsException ignored) { + // Validate the existing entry without following a symbolic link. + } + validateRealDirectory(directory); + } + + private static void validateShardIfPresent(Path directory) { + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + validateRealDirectory(directory); + } + } + + private static void validateRealDirectory(Path directory) { + if (Files.isSymbolicLink(directory) + || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new LocalPublicationJournalException("local publication control directory is invalid"); + } + } + + private static void restrictDirectory(Path path) throws IOException { + try { + Files.setPosixFilePermissions( + path, + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE)); + } catch (UnsupportedOperationException ignored) { + // Deployment qualification owns non-POSIX permission evidence. + } + } + + private static void restrictFile(Path path) throws IOException { + try { + Files.setPosixFilePermissions( + path, Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } catch (UnsupportedOperationException ignored) { + // Deployment qualification owns non-POSIX permission evidence. + } + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static String token(String operationId) { + try { + return HEX.formatHex( + MessageDigest.getInstance("SHA-256") + .digest(operationId.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static void deleteTemporary(Path temporary, Throwable original) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + original.addSuppressed(cleanupFailure); + } + } + + private static Semaphore[] createLockStripes() { + Semaphore[] locks = new Semaphore[JVM_LOCK_STRIPE_COUNT]; + for (int index = 0; index < locks.length; index++) { + locks[index] = new Semaphore(1); + } + return locks; + } + + private static void closeAfterAcquireFailure(FileChannel channel, Throwable original) { + if (channel == null) { + return; + } + try { + channel.close(); + } catch (IOException closeFailure) { + original.addSuppressed(closeFailure); + } + } + + static final class OperationLock implements AutoCloseable { + + private final FileLock fileLock; + private final FileChannel channel; + private final Semaphore jvmLock; + private boolean closed; + + private OperationLock(FileLock fileLock, FileChannel channel, Semaphore jvmLock) { + this.fileLock = fileLock; + this.channel = channel; + this.jvmLock = jvmLock; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + fileLock.release(); + } catch (IOException exception) { + failure = exception; + } + try { + channel.close(); + } catch (IOException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } finally { + jvmLock.release(); + } + if (failure != null) { + throw new LocalPublicationJournalException( + "local publication operation lock cannot be released", failure); + } + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java new file mode 100644 index 0000000..9ce87d5 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java @@ -0,0 +1,245 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Strict bounded codec for the flat local operation-journal JSON document. */ +final class LocalPublicationJournalCodec { + + private static final Set KEYS = + Set.of( + "schemaVersion", + "state", + "operationId", + "requestFingerprint", + "publishedFileName", + "stageFileName", + "byteSize", + "rowCount", + "columnCount", + "sha256", + "formulaMitigatedCount", + "publishedAt", + "publicationGuarantee"); + + private LocalPublicationJournalCodec() {} + + static byte[] encode(LocalPublicationJournalRecord record) { + StringBuilder json = new StringBuilder(768); + json.append('{'); + number(json, "schemaVersion", record.schemaVersion()); + string(json, "state", record.state().name()); + string(json, "operationId", record.operationId()); + string(json, "requestFingerprint", record.requestFingerprint()); + string(json, "publishedFileName", record.publishedFileName()); + string(json, "stageFileName", record.stageFileName()); + number(json, "byteSize", record.byteSize()); + number(json, "rowCount", record.rowCount()); + number(json, "columnCount", record.columnCount()); + string(json, "sha256", record.sha256()); + number(json, "formulaMitigatedCount", record.formulaMitigatedCount()); + string(json, "publishedAt", record.publishedAt()); + string(json, "publicationGuarantee", record.publicationGuarantee()); + json.append("}\n"); + return json.toString().getBytes(StandardCharsets.UTF_8); + } + + static LocalPublicationJournalRecord decode(byte[] bytes) { + try { + Map values = new FlatJsonParser(bytes).parse(); + if (!values.keySet().equals(KEYS)) { + throw new IllegalArgumentException("journal fields do not match schema"); + } + return new LocalPublicationJournalRecord( + integer(values, "schemaVersion"), + LocalPublicationJournalRecord.State.valueOf(values.get("state")), + values.get("operationId"), + values.get("requestFingerprint"), + values.get("publishedFileName"), + values.get("stageFileName"), + longValue(values, "byteSize"), + longValue(values, "rowCount"), + integer(values, "columnCount"), + values.get("sha256"), + longValue(values, "formulaMitigatedCount"), + values.get("publishedAt"), + values.get("publicationGuarantee")); + } catch (RuntimeException exception) { + throw new LocalPublicationJournalException("local publication journal is corrupt", exception); + } + } + + private static int integer(Map values, String key) { + return Integer.parseInt(values.get(key)); + } + + private static long longValue(Map values, String key) { + return Long.parseLong(values.get(key)); + } + + private static void number(StringBuilder json, String key, long value) { + fieldPrefix(json, key); + json.append(value); + } + + private static void string(StringBuilder json, String key, String value) { + fieldPrefix(json, key); + appendQuoted(json, value); + } + + private static void fieldPrefix(StringBuilder json, String key) { + if (json.length() > 1) { + json.append(','); + } + appendQuoted(json, key); + json.append(':'); + } + + private static void appendQuoted(StringBuilder target, String value) { + target.append('"'); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '"' -> target.append("\\\""); + case '\\' -> target.append("\\\\"); + case '\b' -> target.append("\\b"); + case '\f' -> target.append("\\f"); + case '\n' -> target.append("\\n"); + case '\r' -> target.append("\\r"); + case '\t' -> target.append("\\t"); + default -> { + if (character < 0x20) { + target.append("\\u%04x".formatted((int) character)); + } else { + target.append(character); + } + } + } + } + target.append('"'); + } + + private static final class FlatJsonParser { + + private static final int MAXIMUM_JOURNAL_BYTES = 16_384; + + private final String input; + private int cursor; + + private FlatJsonParser(byte[] bytes) { + if (bytes.length < 2 || bytes.length > MAXIMUM_JOURNAL_BYTES) { + throw new IllegalArgumentException("journal size is out of bounds"); + } + input = new String(bytes, StandardCharsets.UTF_8); + } + + private Map parse() { + Map values = new LinkedHashMap<>(); + whitespace(); + expect('{'); + whitespace(); + while (!peek('}')) { + String key = quoted(); + if (!KEYS.contains(key)) { + throw new IllegalArgumentException("unknown journal field"); + } + whitespace(); + expect(':'); + whitespace(); + String value = peek('"') ? quoted() : number(); + if (values.putIfAbsent(key, value) != null) { + throw new IllegalArgumentException("duplicate journal field"); + } + whitespace(); + if (peek(',')) { + cursor++; + whitespace(); + } else { + break; + } + } + expect('}'); + whitespace(); + if (cursor != input.length()) { + throw new IllegalArgumentException("trailing journal content"); + } + return values; + } + + private String quoted() { + expect('"'); + StringBuilder value = new StringBuilder(); + while (cursor < input.length()) { + char character = input.charAt(cursor++); + if (character == '"') { + return value.toString(); + } + if (character != '\\') { + if (character < 0x20) { + throw new IllegalArgumentException("unescaped control character"); + } + value.append(character); + continue; + } + if (cursor >= input.length()) { + throw new IllegalArgumentException("truncated escape"); + } + char escape = input.charAt(cursor++); + switch (escape) { + case '"' -> value.append('"'); + case '\\' -> value.append('\\'); + case 'b' -> value.append('\b'); + case 'f' -> value.append('\f'); + case 'n' -> value.append('\n'); + case 'r' -> value.append('\r'); + case 't' -> value.append('\t'); + case 'u' -> value.append(unicode()); + default -> throw new IllegalArgumentException("invalid escape"); + } + } + throw new IllegalArgumentException("unterminated string"); + } + + private char unicode() { + if (cursor + 4 > input.length()) { + throw new IllegalArgumentException("truncated unicode escape"); + } + int value = Integer.parseInt(input.substring(cursor, cursor + 4), 16); + cursor += 4; + return (char) value; + } + + private String number() { + int start = cursor; + if (peek('-')) { + cursor++; + } + while (cursor < input.length() && Character.isDigit(input.charAt(cursor))) { + cursor++; + } + if (cursor == start || (cursor == start + 1 && input.charAt(start) == '-')) { + throw new IllegalArgumentException("invalid number"); + } + return input.substring(start, cursor); + } + + private void whitespace() { + while (cursor < input.length() && Character.isWhitespace(input.charAt(cursor))) { + cursor++; + } + } + + private boolean peek(char expected) { + return cursor < input.length() && input.charAt(cursor) == expected; + } + + private void expect(char expected) { + if (!peek(expected)) { + throw new IllegalArgumentException("unexpected journal token"); + } + cursor++; + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalException.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalException.java new file mode 100644 index 0000000..fcc75af --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalException.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +/** Internal control-plane failure that never includes a filesystem path. */ +final class LocalPublicationJournalException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + LocalPublicationJournalException(String message) { + super(message); + } + + LocalPublicationJournalException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalRecord.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalRecord.java new file mode 100644 index 0000000..91795ac --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalRecord.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.time.Instant; +import java.util.Objects; + +/** Immutable local control record; internal locators are relative file names only. */ +record LocalPublicationJournalRecord( + int schemaVersion, + State state, + String operationId, + String requestFingerprint, + String publishedFileName, + String stageFileName, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + String publishedAt, + String publicationGuarantee) { + + static final int CURRENT_SCHEMA_VERSION = 1; + + LocalPublicationJournalRecord { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("unsupported local publication journal schema"); + } + Objects.requireNonNull(state, "state must be non-null"); + requireText(operationId, "operationId", 128); + requireDigest(requestFingerprint, "requestFingerprint"); + requireFileName(publishedFileName, "publishedFileName"); + requireFileName(stageFileName, "stageFileName"); + if (byteSize < 0 || rowCount < 0 || columnCount < 0 || formulaMitigatedCount < 0) { + throw new IllegalArgumentException("journal counts must be non-negative"); + } + if (state != State.WRITING) { + requireDigest(sha256, "sha256"); + if (columnCount < 1) { + throw new IllegalArgumentException("sealed journal columnCount must be positive"); + } + } else if (!sha256.isEmpty()) { + throw new IllegalArgumentException("writing journal must not contain a digest"); + } + if (state == State.PUBLISHED) { + Instant.parse(publishedAt); + if (publicationGuarantee.isBlank()) { + throw new IllegalArgumentException("published journal must contain publicationGuarantee"); + } + } else if (!publishedAt.isEmpty() || !publicationGuarantee.isEmpty()) { + throw new IllegalArgumentException("non-terminal journal must not contain receipt fields"); + } + } + + static LocalPublicationJournalRecord writing( + String operationId, + String requestFingerprint, + String publishedFileName, + String stageFileName) { + return new LocalPublicationJournalRecord( + CURRENT_SCHEMA_VERSION, + State.WRITING, + operationId, + requestFingerprint, + publishedFileName, + stageFileName, + 0, + 0, + 0, + "", + 0, + "", + ""); + } + + static LocalPublicationJournalRecord sealed( + String operationId, + String requestFingerprint, + String publishedFileName, + String stageFileName, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount) { + return new LocalPublicationJournalRecord( + CURRENT_SCHEMA_VERSION, + State.SEALED, + operationId, + requestFingerprint, + publishedFileName, + stageFileName, + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + "", + ""); + } + + LocalPublicationJournalRecord published(Instant time, String guarantee) { + return new LocalPublicationJournalRecord( + schemaVersion, + State.PUBLISHED, + operationId, + requestFingerprint, + publishedFileName, + stageFileName, + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + Objects.requireNonNull(time, "time must be non-null").toString(), + guarantee); + } + + private static void requireDigest(String value, String field) { + if (value == null || !value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest"); + } + } + + private static void requireFileName(String value, String field) { + requireText(value, field, 256); + if (value.contains("/") || value.contains("\\") || value.equals(".") || value.equals("..")) { + throw new IllegalArgumentException(field + " must be a relative file name"); + } + } + + private static void requireText(String value, String field, int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength + || value.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " is invalid"); + } + } + + enum State { + WRITING, + SEALED, + PUBLISHED + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java new file mode 100644 index 0000000..140dca9 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.TabularCell; +import dev.caskeleton.application.filepublication.TabularCell.BooleanCell; +import dev.caskeleton.application.filepublication.TabularCell.DateCell; +import dev.caskeleton.application.filepublication.TabularCell.DecimalCell; +import dev.caskeleton.application.filepublication.TabularCell.InstantCell; +import dev.caskeleton.application.filepublication.TabularCell.IntegerCell; +import dev.caskeleton.application.filepublication.TabularCell.NullCell; +import dev.caskeleton.application.filepublication.TabularCell.TextCell; +import dev.caskeleton.application.filepublication.TabularRow; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +/** Attempt-scoped row-at-a-time RFC-4180 encoder with schema and byte bounds. */ +final class StreamingCsvEncoder { + + private static final HexFormat HEX = HexFormat.of(); + + private final ExportSchema schema; + private final OutputStream output; + private final MessageDigest digest; + private final long maximumRows; + private final long maximumBytes; + + private long bytesWritten; + private long rowsWritten; + private long formulaMitigated; + + StreamingCsvEncoder( + ExportSchema schema, + OutputStream output, + MessageDigest digest, + long maximumRows, + long maximumBytes) { + this.schema = schema; + this.output = output; + this.digest = digest; + this.maximumRows = maximumRows; + this.maximumBytes = maximumBytes; + } + + void writeHeader() { + writeRecord(schema.columns().stream().map(ExportSchema.Column::name).toList()); + } + + void write(TabularRow row) { + checkpoint(); + if (rowsWritten >= maximumRows) { + throw failure( + FilePublicationException.Reason.CAPACITY_EXCEEDED, "file publication row limit exceeded"); + } + List cells = row.cells(); + if (cells.size() != schema.columns().size()) { + throw failure( + FilePublicationException.Reason.INVALID_REQUEST, + "row column count does not match schema"); + } + String[] values = new String[cells.size()]; + for (int index = 0; index < cells.size(); index++) { + values[index] = encodeCell(cells.get(index), schema.columns().get(index)); + } + writeRecord(List.of(values)); + rowsWritten++; + checkpoint(); + } + + void checkpoint() { + if (Thread.currentThread().isInterrupted()) { + throw failure(FilePublicationException.Reason.CANCELLED, "file publication cancelled"); + } + } + + Stats finish() { + return new Stats(bytesWritten, rowsWritten, formulaMitigated, HEX.formatHex(digest.digest())); + } + + private String encodeCell(TabularCell cell, ExportSchema.Column column) { + if (cell instanceof NullCell) { + if (!column.nullable()) { + throw failure( + FilePublicationException.Reason.INVALID_REQUEST, + "null cell is not allowed for column " + column.name()); + } + return ""; + } + if (cell.cellType() != column.cellType()) { + throw failure( + FilePublicationException.Reason.INVALID_REQUEST, + "cell type does not match schema column " + column.name()); + } + + String rendered = + switch (cell) { + case TextCell text -> encodeText(text.value(), column); + case IntegerCell integer -> Long.toString(integer.value()); + case DecimalCell decimal -> decimal.value().toPlainString(); + case BooleanCell bool -> Boolean.toString(bool.value()); + case DateCell date -> date.value().toString(); + case InstantCell instant -> instant.value().toString(); + case NullCell ignored -> throw new IllegalStateException("handled above"); + }; + + int bytes = rendered.getBytes(StandardCharsets.UTF_8).length; + if (bytes > column.maximumUtf8Bytes()) { + throw failure( + FilePublicationException.Reason.CAPACITY_EXCEEDED, + "cell byte limit exceeded for column " + column.name()); + } + return rendered; + } + + private String encodeText(String value, ExportSchema.Column column) { + if (!isFormulaCandidate(value)) { + return value; + } + return switch (column.formulaPolicy()) { + case ALLOW -> value; + case MITIGATE -> { + formulaMitigated++; + yield "'" + value; + } + case REJECT -> + throw failure( + FilePublicationException.Reason.INVALID_REQUEST, + "spreadsheet formula text rejected for column " + column.name()); + }; + } + + private void writeRecord(List fields) { + StringBuilder row = new StringBuilder(); + for (int index = 0; index < fields.size(); index++) { + if (index > 0) { + row.append(','); + } + row.append(escape(fields.get(index))); + } + row.append('\n'); + writeBytes(row.toString().getBytes(StandardCharsets.UTF_8)); + } + + private void writeBytes(byte[] bytes) { + if (bytesWritten > maximumBytes - bytes.length) { + throw failure( + FilePublicationException.Reason.CAPACITY_EXCEEDED, + "file publication byte limit exceeded"); + } + try { + output.write(bytes); + digest.update(bytes); + bytesWritten += bytes.length; + } catch (IOException exception) { + throw new FilePublicationException( + FilePublicationException.Reason.UNAVAILABLE, "file publication write failed", exception); + } + } + + private static String escape(String value) { + if (value.contains(",") + || value.contains("\"") + || value.contains("\n") + || value.contains("\r")) { + return '"' + value.replace("\"", "\"\"") + '"'; + } + return value; + } + + private static boolean isFormulaCandidate(String value) { + if (value.isEmpty()) { + return false; + } + return switch (value.charAt(0)) { + case '=', '+', '-', '@', '\t', '\r' -> true; + default -> false; + }; + } + + private static FilePublicationException failure( + FilePublicationException.Reason reason, String message) { + return new FilePublicationException(reason, message); + } + + record Stats(long byteSize, long rowCount, long formulaMitigatedCount, String sha256) {} +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java new file mode 100644 index 0000000..0acedb8 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.fileexport.FileExportPort; +import dev.caskeleton.application.filepublication.FilePublicationPort; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class FilePublicationConfigTest { + + @TempDir Path tempDir; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(FileExportConfig.class); + + @Test + void remainsDisabledUnlessExplicitlyEnabled() { + runner.run( + context -> { + assertThat(context).doesNotHaveBean(FileExportPort.class); + assertThat(context).doesNotHaveBean(FilePublicationPort.class); + }); + } + + @Test + void bindsPublicationLimitsAndContributesOnlyTheStreamingPortByDefault() { + runner + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.base-directory=build/test-files", + "ca-skeleton.fileserver.destination-id=nightly-export", + "ca-skeleton.fileserver.maximum-rows=125", + "ca-skeleton.fileserver.maximum-encoded-bytes=4096") + .run( + context -> { + assertThat(context).doesNotHaveBean(FileExportPort.class); + assertThat(context).hasSingleBean(FilePublicationPort.class); + + FileExportProperties properties = context.getBean(FileExportProperties.class); + assertThat(properties.getDestinationId()).isEqualTo("nightly-export"); + assertThat(properties.getMaximumRows()).isEqualTo(125); + assertThat(properties.getMaximumEncodedBytes()).isEqualTo(4096); + }); + } + + @Test + void legacyPortRequiresItsOwnOptInAndSeparateRoot() { + runner + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.base-directory=build/test-files", + "ca-skeleton.fileserver.legacy-enabled=true", + "ca-skeleton.fileserver.legacy-base-directory=build/test-files-legacy") + .run( + context -> { + assertThat(context).hasSingleBean(FileExportPort.class); + assertThat(context).hasSingleBean(FilePublicationPort.class); + FileExportProperties properties = context.getBean(FileExportProperties.class); + assertThat(properties.getLegacyBaseDirectory()).isEqualTo("build/test-files-legacy"); + }); + } + + @Test + void rejectsBlankPublicationRootAndOverlappingLegacyRoot() { + runner + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", "ca-skeleton.fileserver.base-directory= ") + .run(context -> assertThat(context).hasFailed()); + + runner + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.legacy-enabled=true", + "ca-skeleton.fileserver.base-directory=build/shared-files", + "ca-skeleton.fileserver.legacy-base-directory=build/shared-files/legacy") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "fileserver publication and legacy roots must not overlap")); + } + + @Test + void rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink() throws Exception { + Path publicationRoot = Files.createDirectory(tempDir.resolve("publication")); + Path legacyAlias = tempDir.resolve("legacy-alias"); + Files.createSymbolicLink(legacyAlias, publicationRoot); + + runner + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.legacy-enabled=true", + "ca-skeleton.fileserver.base-directory=" + publicationRoot, + "ca-skeleton.fileserver.legacy-base-directory=" + legacyAlias) + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "fileserver publication and legacy roots must not overlap")); + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java index 7066d44..bd892b8 100644 --- a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java @@ -94,6 +94,14 @@ class FilesystemCsvExportAdapterTest { .isInstanceOf(IllegalArgumentException.class); } + @Test + void nestedControlLikePathIsRejected() { + assertThatThrownBy( + () -> + adapter.exportCsv(".ca-fileserver/operations/record.json", List.of("a"), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + @Test void blankFileNameIsRejected() { assertThatThrownBy(() -> adapter.exportCsv(" ", List.of("a"), List.of())) diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java new file mode 100644 index 0000000..f5895b8 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java @@ -0,0 +1,235 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.LogicalFileName; +import dev.caskeleton.application.filepublication.SourceRevision; +import dev.caskeleton.application.filepublication.TabularCell.IntegerCell; +import dev.caskeleton.application.filepublication.TabularCell.TextCell; +import dev.caskeleton.application.filepublication.TabularRow; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalFilePublicationAdapterTest { + + private static final FileDestinationId DESTINATION = new FileDestinationId("local-export"); + + @TempDir Path tempDir; + + private LocalFilePublicationAdapter adapter; + + @BeforeEach + void setUp() { + adapter = + new LocalFilePublicationAdapter( + new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 1024)); + } + + @Test + void publishesRowsThroughTheSinkAndReturnsAnOpaqueReceipt() throws IOException { + AtomicInteger producerCalls = new AtomicInteger(); + + FilePublishReceipt receipt = + adapter.publish( + request(), + sink -> { + producerCalls.incrementAndGet(); + sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd")))); + }); + + assertThat(producerCalls).hasValue(1); + assertThat(receipt.reference().value()).doesNotContain(tempDir.toString()); + assertThat(receipt.sha256()).hasSize(64); + assertThat(receipt.dataRowCount()).isEqualTo(1); + assertThat(receipt.formulaMitigatedCount()).isEqualTo(1); + + Path published = onlyPublishedCsv(); + assertThat(Files.readString(published, UTF_8)).isEqualTo("id,note\n1,'=cmd\n"); + } + + @Test + void removesStagingAndLeavesNoFinalArtifactWhenByteLimitIsExceeded() throws IOException { + LocalFilePublicationAdapter tinyAdapter = + new LocalFilePublicationAdapter( + new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 16)); + + assertThatThrownBy( + () -> + tinyAdapter.publish( + request(), + sink -> + sink.write( + new TabularRow( + List.of(new IntegerCell(1), new TextCell("x".repeat(100))))))) + .isInstanceOf(FilePublicationException.class) + .hasMessageContaining("byte"); + + assertThat(publishedCsvFiles()).isEmpty(); + try (Stream staging = Files.list(tempDir.resolve(".staging"))) { + assertThat(staging).isEmpty(); + } + } + + @Test + void rejectsWrongCellTypeAndDeletesTheStagingFile() throws IOException { + assertThatThrownBy( + () -> + adapter.publish( + request(), + sink -> + sink.write( + new TabularRow( + List.of(new TextCell("not-an-id"), new TextCell("ok")))))) + .isInstanceOf(FilePublicationException.class) + .hasMessageContaining("cell type"); + + assertThat(publishedCsvFiles()).isEmpty(); + try (Stream staging = Files.list(tempDir.resolve(".staging"))) { + assertThat(staging).isEmpty(); + } + } + + @Test + void propagatesProducerFailureAndDeletesStaging() throws IOException { + IllegalStateException sourceFailure = new IllegalStateException("source unavailable"); + + assertThatThrownBy( + () -> + adapter.publish( + request(), + sink -> { + throw sourceFailure; + })) + .isSameAs(sourceFailure); + + assertThat(publishedCsvFiles()).isEmpty(); + try (Stream staging = Files.list(tempDir.resolve(".staging"))) { + assertThat(staging).isEmpty(); + } + } + + @Test + void targetConflictAfterSealingPreservesTheOnlyRecoveryArtifact() throws Exception { + FilePublishRequest request = request(); + String token = + HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(request.operationId().value().getBytes(UTF_8))) + .substring(0, 24); + Path target = tempDir.resolve(request.logicalFileName().value() + "--" + token + ".csv"); + + assertThatThrownBy( + () -> + adapter.publish( + request, + sink -> { + sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("ok")))); + try { + Files.writeString(target, "collision", UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + })) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()).isEqualTo(FilePublicationException.Reason.CONFLICT)); + + assertThat(Files.readString(target, UTF_8)).isEqualTo("collision"); + try (Stream staging = Files.list(tempDir.resolve(".staging"))) { + assertThat(staging.filter(Files::isRegularFile)).hasSize(1); + } + } + + @Test + void unavailableAtomicPrimitiveFailsClosedAndPreservesTheSealedStage() throws Exception { + LocalFilePublicationPolicy failClosedPolicy = + new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 1024); + LocalFilePublicationAdapter failingAdapter = + new LocalFilePublicationAdapter( + failClosedPolicy, + (target, staging) -> { + throw new IOException("hard links unavailable"); + }); + FilePublishRequest request = request(); + String token = + HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(request.operationId().value().getBytes(UTF_8))) + .substring(0, 24); + Path target = tempDir.resolve(request.logicalFileName().value() + "--" + token + ".csv"); + + assertThatThrownBy( + () -> + failingAdapter.publish( + request, + sink -> + sink.write( + new TabularRow(List.of(new IntegerCell(1), new TextCell("ok")))))) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.UNAVAILABLE)); + assertThat(target).doesNotExist(); + try (Stream staging = Files.list(tempDir.resolve(".staging"))) { + assertThat(staging.filter(Files::isRegularFile)).hasSize(1); + } + } + + private FilePublishRequest request() { + return new FilePublishRequest( + new FilePublishOperationId("01J1234567890ABCDEFGHJKMNP"), + DESTINATION, + new LogicalFileName("worklogs"), + new SourceRevision("snapshot-42"), + new ExportSchema( + "worklog-v1", + 1, + List.of( + new ExportSchema.Column( + "id", + ExportSchema.CellType.INTEGER, + false, + ExportSchema.FormulaPolicy.REJECT, + 64), + new ExportSchema.Column( + "note", + ExportSchema.CellType.TEXT, + false, + ExportSchema.FormulaPolicy.MITIGATE, + 128))), + "csv-rfc4180-v1"); + } + + private Path onlyPublishedCsv() throws IOException { + assertThat(publishedCsvFiles()).hasSize(1); + return publishedCsvFiles().getFirst(); + } + + private List publishedCsvFiles() throws IOException { + try (Stream files = Files.list(tempDir)) { + return files.filter(path -> path.getFileName().toString().endsWith(".csv")).toList(); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationRecoveryTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationRecoveryTest.java new file mode 100644 index 0000000..2d2ed60 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationRecoveryTest.java @@ -0,0 +1,280 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.CellType; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.LogicalFileName; +import dev.caskeleton.application.filepublication.SourceRevision; +import dev.caskeleton.application.filepublication.TabularCell.IntegerCell; +import dev.caskeleton.application.filepublication.TabularCell.TextCell; +import dev.caskeleton.application.filepublication.TabularRow; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalFilePublicationRecoveryTest { + + private static final FileDestinationId DESTINATION = new FileDestinationId("local-export"); + + @TempDir Path tempDir; + + private LocalFilePublicationPolicy policy; + + @BeforeEach + void setUp() { + policy = new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 1024); + } + + @Test + void completedOperationIsRestoredAfterRestartWithoutCallingProducer() { + LocalFilePublicationAdapter first = new LocalFilePublicationAdapter(policy); + FilePublishReceipt original = first.publish(request("source-1"), this::produceOneRow); + AtomicInteger producerCalls = new AtomicInteger(); + + FilePublishReceipt restored = + new LocalFilePublicationAdapter(policy) + .publish( + request("source-1"), + sink -> { + producerCalls.incrementAndGet(); + throw new AssertionError("terminal retry must not invoke producer"); + }); + + assertThat(producerCalls).hasValue(0); + assertThat(restored).isEqualTo(original); + } + + @Test + void reusedOperationIdWithDifferentIntentIsAConflict() { + new LocalFilePublicationAdapter(policy).publish(request("source-1"), this::produceOneRow); + AtomicInteger producerCalls = new AtomicInteger(); + + assertThatThrownBy( + () -> + new LocalFilePublicationAdapter(policy) + .publish(request("source-2"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()).isEqualTo(FilePublicationException.Reason.CONFLICT)); + assertThat(producerCalls).hasValue(0); + } + + @Test + void terminalRecordWithMutatedArtifactIsIndeterminate() throws Exception { + FilePublishReceipt receipt = + new LocalFilePublicationAdapter(policy).publish(request("source-1"), this::produceOneRow); + Files.writeString(tempDir.resolve(receipt.publishedFileName()), "mutated", UTF_8); + AtomicInteger producerCalls = new AtomicInteger(); + + assertThatThrownBy( + () -> + new LocalFilePublicationAdapter(policy) + .publish(request("source-1"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + assertThat(producerCalls).hasValue(0); + } + + @Test + void corruptOperationJournalIsMappedToTheProviderNeutralIndeterminateFailure() throws Exception { + FilePublishRequest request = request("source-1"); + new LocalFilePublicationAdapter(policy).publish(request, this::produceOneRow); + LocalPublicationJournal journal = new LocalPublicationJournal(tempDir); + Files.writeString( + journal.recordPath(request.operationId().value()), "{\"corrupt\":true}", UTF_8); + + assertThatThrownBy( + () -> new LocalFilePublicationAdapter(policy).publish(request, this::produceOneRow)) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + } + + @Test + void sealedStagingIsPublishedAfterRestartWithoutCallingProducer() throws Exception { + FilePublishRequest request = request("source-1"); + String operationToken = token(request.operationId().value()); + String stageName = "." + operationToken + ".part"; + String publishedName = request.logicalFileName().value() + "--" + operationToken + ".csv"; + byte[] payload = "id,note\n1,ok\n".getBytes(UTF_8); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); + Path stagingDirectory = tempDir.resolve(".staging"); + Files.createDirectories(stagingDirectory); + Files.write(stagingDirectory.resolve(stageName), payload); + LocalPublicationJournal journal = new LocalPublicationJournal(tempDir); + journal.store( + LocalPublicationJournalRecord.sealed( + request.operationId().value(), + FilePublishRequestFingerprint.calculate(request), + publishedName, + stageName, + payload.length, + 1, + 2, + digest, + 0)); + AtomicInteger producerCalls = new AtomicInteger(); + + FilePublishReceipt receipt = + new LocalFilePublicationAdapter(policy) + .publish(request, sink -> producerCalls.incrementAndGet()); + + assertThat(producerCalls).hasValue(0); + assertThat(Files.readAllBytes(tempDir.resolve(publishedName))).isEqualTo(payload); + assertThat(receipt.sha256()).isEqualTo(digest); + } + + @Test + void sealedTargetRecoveryReconstructsTheHardLinkCommitWithoutCallingProducer() throws Exception { + FilePublishRequest request = request("source-1"); + String operationToken = token(request.operationId().value()); + String stageName = "." + operationToken + ".part"; + String publishedName = request.logicalFileName().value() + "--" + operationToken + ".csv"; + byte[] payload = "id,note\n1,ok\n".getBytes(UTF_8); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); + Files.write(tempDir.resolve(publishedName), payload); + new LocalPublicationJournal(tempDir) + .store( + LocalPublicationJournalRecord.sealed( + request.operationId().value(), + FilePublishRequestFingerprint.calculate(request), + publishedName, + stageName, + payload.length, + 1, + 2, + digest, + 0)); + + FilePublishReceipt receipt = + new LocalFilePublicationAdapter(policy) + .publish( + request, + sink -> { + throw new AssertionError("sealed target recovery must not invoke producer"); + }); + + assertThat(receipt.publicationGuarantee()) + .isEqualTo(FilePublishReceipt.PublicationGuarantee.UNIQUE_ATOMIC_CREATE); + } + + @Test + void concurrentAdaptersSerializeOneOperationAndInvokeTheProducerOnce() throws Exception { + LocalFilePublicationAdapter firstAdapter = new LocalFilePublicationAdapter(policy); + LocalFilePublicationAdapter secondAdapter = new LocalFilePublicationAdapter(policy); + CountDownLatch firstProducerStarted = new CountDownLatch(1); + CountDownLatch releaseFirstProducer = new CountDownLatch(1); + CountDownLatch secondCallStarted = new CountDownLatch(1); + CountDownLatch secondCallFinished = new CountDownLatch(1); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicReference firstReceipt = new AtomicReference<>(); + AtomicReference secondReceipt = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread first = + Thread.ofPlatform() + .start( + () -> { + try { + firstReceipt.set( + firstAdapter.publish( + request("source-1"), + sink -> { + producerCalls.incrementAndGet(); + firstProducerStarted.countDown(); + try { + releaseFirstProducer.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exception); + } + produceOneRow(sink); + })); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + + assertThat(firstProducerStarted.await(1, TimeUnit.SECONDS)).isTrue(); + Thread second = + Thread.ofPlatform() + .start( + () -> { + secondCallStarted.countDown(); + try { + secondReceipt.set( + secondAdapter.publish( + request("source-1"), + sink -> { + producerCalls.incrementAndGet(); + produceOneRow(sink); + })); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + secondCallFinished.countDown(); + } + }); + assertThat(secondCallStarted.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(secondCallFinished.await(100, TimeUnit.MILLISECONDS)).isFalse(); + + releaseFirstProducer.countDown(); + first.join(2_000); + second.join(2_000); + + assertThat(first.isAlive()).isFalse(); + assertThat(second.isAlive()).isFalse(); + assertThat(failure.get()).isNull(); + assertThat(producerCalls).hasValue(1); + assertThat(secondReceipt.get()).isEqualTo(firstReceipt.get()); + } + + private void produceOneRow(dev.caskeleton.application.filepublication.TabularRowSink sink) { + sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("ok")))); + } + + private static FilePublishRequest request(String sourceRevision) { + return new FilePublishRequest( + new FilePublishOperationId("operation-1"), + DESTINATION, + new LogicalFileName("report"), + new SourceRevision(sourceRevision), + new ExportSchema( + "work-log", + 1, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32), + new Column("note", CellType.TEXT, false, FormulaPolicy.MITIGATE, 256))), + "csv-rfc4180-v1"); + } + + private static String token(String operationId) throws Exception { + return HexFormat.of() + .formatHex(MessageDigest.getInstance("SHA-256").digest(operationId.getBytes(UTF_8))) + .substring(0, 24); + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java new file mode 100644 index 0000000..f335a75 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.CellType; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.LogicalFileName; +import dev.caskeleton.application.filepublication.SourceRevision; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalPublicationJournalTest { + + @TempDir Path tempDir; + + @Test + void requestFingerprintIsStableAndCoversSourceAndSchema() { + String first = FilePublishRequestFingerprint.calculate(request("source-1", 1)); + String same = FilePublishRequestFingerprint.calculate(request("source-1", 1)); + String differentSource = FilePublishRequestFingerprint.calculate(request("source-2", 1)); + String differentSchema = FilePublishRequestFingerprint.calculate(request("source-1", 2)); + + assertThat(first).matches("[0-9a-f]{64}").isEqualTo(same); + assertThat(differentSource).isNotEqualTo(first); + assertThat(differentSchema).isNotEqualTo(first); + } + + @Test + void journalRoundTripsASealedRecordAndRejectsCorruption() throws Exception { + LocalPublicationJournal journal = new LocalPublicationJournal(tempDir); + LocalPublicationJournalRecord sealed = + LocalPublicationJournalRecord.sealed( + "operation-1", + "1".repeat(64), + "report--token.csv", + ".token.part", + 42, + 3, + 2, + "a".repeat(64), + 1); + + journal.store(sealed); + + assertThat(journal.find("operation-1")).contains(sealed); + + Path record = journal.recordPath("operation-1"); + Files.writeString( + record, Files.readString(record).replace("\"byteSize\":42", "\"byteSize\":x")); + + assertThatThrownBy(() -> journal.find("operation-1")) + .isInstanceOf(LocalPublicationJournalException.class) + .hasMessageContaining("corrupt"); + } + + @Test + void rejectsSymlinkedControlDirectoryWithoutWritingOutsideTheBase() throws Exception { + Path base = Files.createDirectory(tempDir.resolve("base")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Files.createSymbolicLink(base.resolve(".ca-fileserver"), outside); + + assertThatThrownBy(() -> new LocalPublicationJournal(base)) + .isInstanceOf(LocalPublicationJournalException.class); + assertThat(outside.resolve("operations")).doesNotExist(); + } + + @Test + void rejectsSymlinkedJournalShardWithoutWritingOutsideTheControlDirectory() throws Exception { + LocalPublicationJournal journal = new LocalPublicationJournal(tempDir); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Path shard = journal.recordPath("operation-1").getParent(); + Files.createSymbolicLink(shard, outside); + LocalPublicationJournalRecord sealed = + LocalPublicationJournalRecord.sealed( + "operation-1", + "1".repeat(64), + "report--token.csv", + ".token.part", + 42, + 3, + 2, + "a".repeat(64), + 1); + + assertThatThrownBy(() -> journal.store(sealed)) + .isInstanceOf(LocalPublicationJournalException.class); + assertThat(outside).isEmptyDirectory(); + } + + private static FilePublishRequest request(String sourceRevision, int schemaVersion) { + return new FilePublishRequest( + new FilePublishOperationId("operation-1"), + new FileDestinationId("local-export"), + new LogicalFileName("report"), + new SourceRevision(sourceRevision), + new ExportSchema( + "work-log", + schemaVersion, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32), + new Column("note", CellType.TEXT, false, FormulaPolicy.MITIGATE, 256))), + "csv-rfc4180-v1"); + } +} diff --git a/src/adapter/outbound/httpclient/CLAUDE.md b/src/adapter/outbound/httpclient/CLAUDE.md index 89986f0..97fbc66 100644 --- a/src/adapter/outbound/httpclient/CLAUDE.md +++ b/src/adapter/outbound/httpclient/CLAUDE.md @@ -4,27 +4,38 @@ - Module ID: `adapter-outbound-httpclient` - Gradle path: `:adapter:outbound:httpclient` -- Focused test: `./gradlew :adapter:outbound:httpclient:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:httpclient:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.httpclient`. ## Responsibility -- Own outbound REST client construction, timeouts, retries, circuit breakers, response-size bounds, - trace propagation, diagnostics, and shutdown safety. +- Own typed destination/operation catalogs, safe target construction, outbound engine construction, + deadlines/cancellation, resilience, request/response bounds, egress security, diagnostics, and + lifecycle. - Adapt external HTTP calls behind application/domain ports. - Reuse `adapter:outbound:support` for shared outbound concerns. ## Boundaries -- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- Allowed dependency edges come only from the module's + `src/config/architecture/modules.json` entry. - No inbound controller/DTO, persistence, bootstrap, or sample dependency. - Retry and circuit-breaker code is technical resilience; business compensation and use-case sequencing stay in application/domain layers. +- Application/domain code must not import this module's generic HTTP client, operation descriptor, + URI, Spring HTTP, JDK/Apache client, retry, or wire DTO types. +- Normal calls use registered fixed destinations and relative operation routes; arbitrary absolute + URL/header/credential APIs are forbidden. +- The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an + Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic + logical-call deadline is R1 evidence only. +- Streaming must validate status before body delivery and remains bounded by a selected readiness + card before production use. ## Tests -Use fake clients/servers or direct collaborator fakes with no real network. Settings receive -binding/validation tests; retry/error mapping and resource bounds receive focused unit tests. +Focused tests may use loopback servers and collaborator fakes. R2 promotion requires explicit +real-network/TLS/pool/cancellation/security lanes and no selected lane may silently skip. diff --git a/src/adapter/outbound/httpclient/README.md b/src/adapter/outbound/httpclient/README.md index dbcffc6..560a1c2 100644 --- a/src/adapter/outbound/httpclient/README.md +++ b/src/adapter/outbound/httpclient/README.md @@ -4,14 +4,38 @@ `dev.caskeleton.adapter.outbound.httpclient`(`resilience`, `diagnostics` 서브패키지 포함). `:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다. -허용/금지 의존 정책은 `src/build.gradle` 의 -`allowedProjectDependencies['adapter:outbound:httpclient']` 항목이 SSOT 다(이 모듈은 아직 별도 -CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 -기록이다. +허용/금지 의존 정책은 `src/config/architecture/modules.json`의 +`adapter-outbound-httpclient` 항목이 SSOT다. 작업 규칙은 `CLAUDE.md`, 상세 목표와 잔여 단계는 +`docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`에 있다. + +## 현재 readiness + +현재 구현은 typed operation/target foundation과 migration용 JDK client를 제공하지만 R2가 아니다. +caller/configured `CallBudget` 교집합을 실제 logical call과 retry backoff에 적용하고 timeout 시 +virtual-thread task를 interrupt하는 active logical deadline은 구현됐다. Apache HC5 pool, pool +acquisition bound, wire hard-cancellation evidence, canonical zero-binding composition, DNS/SSRF, +TLS/auth/proxy, decoded-body bound와 real-network qualification은 아직 없다. + +`application-core`에는 HTTP 타입이 없는 monotonic `CallBudget`만 추가되며 JDK facade의 +`get`/`exchange`/`stream` overload가 이를 소비한다. 실제 product의 +`FraudScreeningPort`, `PartnerCatalogPort` 같은 feature-specific port는 해당 product가 소유한다. +template이 demo business port를 production core에 추가하지 않는다. + +## Typed operation과 target foundation + +`HttpOperationDescriptor`는 destination/operation ID, policy revision, method, relative route, +operation semantics, request/response mode, success status, ordinary retry, 전체 physical attempt와 +response byte 상한을 immutable하게 고정한다. `HttpOperationCatalog`는 startup-time closed +catalog이며 runtime registration API가 없다. + +`FixedHttpDestination`과 `HttpTargetBuilder`는 fixed http(s) authority와 registered relative +route만 결합한다. user-info/query/fragment가 있는 base URI, absolute/scheme-relative request +target, dot traversal, slash를 포함한 path variable, 사전 percent-encoding은 거부한다. ## OutboundHttpClient -단일 명명 의존성(named upstream dependency)용 베이스라인 HTTP 클라이언트. +단일 명명 의존성(named upstream dependency)용 migration HTTP 클라이언트다. 새 application +use case가 이 기술 타입을 직접 주입하는 것은 금지한다. ### static `baseline(...)` 팩토리인 이유 `public final class` + `private` 생성자 + `public static baseline(...)` 형태다. ArchUnit B7 은 @@ -31,9 +55,9 @@ static 팩토리를 쓴다. `InputStream` 을 그대로 전달한다. ### retry 를 CB **바깥**에 두는 이유 -`exchange()` 의 decoration 순서는 CB(outer) → retry(inner)다. retry 를 CB 바깥에 둬야 각 retry -시도가 CB 슬라이딩 윈도에 **독립적으로** 카운트된다. retry 를 CB 안에 두면 모든 재시도가 CB 호출 -1건으로 합산돼 실제 실패 빈도가 CB 에 가려진다. +`exchange()`의 현재 decoration은 retry가 논리 호출을 감싸고, circuit breaker가 각 physical +attempt를 감싼다. 따라서 세 번의 wire attempt는 CB 실패 세 건으로 집계된다. 과거 구현은 CB가 +retry 전체를 감싸 실패 한 건으로만 기록했으며 회귀 테스트로 수정되었다. ### size 위반은 분류하지 않고 전파 `OutboundResponseSizeExceededException` 은 의도적으로 `DependencyFailureException` 이 @@ -44,15 +68,45 @@ static 팩토리를 쓴다. ### streaming 경로에 retry 없음 이미 소비된 스트림은 안전하게 재발행할 수 없다 — reader 에 이미 전달된 바이트는 잃고, 서버가 처음부터 재전송을 보장하지 않는다. 그래서 `stream()` 은 retry 없이 shutdown 게이팅·분류·로깅만 -적용한다. +적용한다. 2xx status를 먼저 확인하고, 4xx/5xx이면 error body를 callback에 넘기지 않고 닫은 뒤 +분류한다. 성공 streaming은 아직 decoded-byte/idle/deadline bound가 없으므로 R2 streaming이 아니다. + +### legacy request-target 방어 + +`exchange()`와 `stream()`은 `/`로 시작하는 relative request target만 허용한다. absolute URI, +scheme-relative authority, fragment, dot traversal과 ambiguous encoded slash/dot을 호출 전에 +거부한다. JDK engine redirect는 `NEVER`로 명시한다. 이 방어는 fixed DNS/address admission이나 +redirect readiness card를 대체하지 않는다. + +### active logical deadline + +기본 overload는 `globalCallTimeout`으로 budget을 만들고, caller budget overload는 둘의 더 짧은 +absolute monotonic deadline을 사용한다. blocking RestClient 호출과 Resilience4j retry/backoff는 +MDC를 복사한 virtual thread 안에서 실행된다. caller는 같은 deadline까지만 기다리고 timeout이면 +task를 interrupt하며 `DEPENDENCY_TIMEOUT`으로 분류한다. retry ThreadLocal도 worker 안에서 +설정·정리되고 worker 진입 및 각 physical supplier 직전에 남은 budget을 다시 확인한다. Caller +thread interrupt는 interrupt flag를 복구한 `CancellationException`으로 보존하며 dependency +장애로 기록하지 않는다. + +이는 JDK provider가 interrupt에 반응하는 범위의 R1 cancellation이다. DNS/TLS/write/body 각 +단계의 wire handle 종료, connection quarantine와 no-leak을 증명하지 않으므로 R2 hard +cancellation 증거가 아니다. Interrupt를 무시하는 provider/callback은 caller 반환 뒤에도 virtual +thread에서 남을 수 있다. 그래서 client별 live worker를 +`maximum-in-flight-calls`(기본 128)로 제한하고, timeout 뒤에도 실제 worker가 종료할 때까지 +admission slot을 반환하지 않는다. 상한이 차면 새 worker를 만들지 않고 즉시 거부한다. + +shutdown guard는 등록된 client executor의 active `FutureTask`를 모두 cancel하고 새 admission을 +닫는다. 다만 interrupt를 무시하는 wire/callback을 강제 종료하거나 모든 cleanup 완료까지 기다리는 +drain/reaper는 아니므로, 이것만으로 R2 hard-cancellation/lifecycle evidence가 되지는 않는다. ### OutboundHttpShutdownGuard — SmartLifecycle 인 이유 `SmartLifecycle` + `getPhase() = Integer.MAX_VALUE`(가장 먼저 stop)로 종료 시 아웃바운드 호출자보다 먼저 멈춘다. `ContextClosedEvent` 를 쓰지 않는 이유: SmartLifecycle phase 순서는 결정적이고 close 시퀀스가 빈을 파괴하기 전에 동작하지만, `ContextClosedEvent` 는 컨텍스트 종료가 시작된 뒤 발생하고 다른 lifecycle 빈과의 순서가 정의되지 않는다. 종료 중에는 -`DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast 한다(전용 shutdown 코드를 새로 만들지 않고 -가장 가까운 버킷을 재사용). +새 호출을 `DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast하고, pre-check와 worker start 사이 +경합은 executor registry가 cancellation으로 닫는다(전용 shutdown 코드를 새로 만들지 않고 가장 +가까운 버킷을 재사용). ### OutboundHttpTimeoutEnforcer — `static @Bean` BeanPostProcessor raw `RestClient`/`RestClient.Builder` 빈이 등록되면 startup 을 실패시키는 BeanPostProcessor 다. @@ -97,7 +151,8 @@ retry/CB 머신을 담는 홀더. `retryFor()` / `circuitBreakerFor()` 는 해 비활성일 때 `null` 을 넘긴다. ### OutboundRetryPolicy — POST/PATCH 는 항상 non-retryable -재시도 조건은 ThreadLocal 호출 컨텍스트 기반의 4가지로, 멱등(idempotent) 메서드만 재시도한다. +재시도 조건은 monotonic `CallBudget`을 보유한 ThreadLocal 호출 컨텍스트 기반의 4가지로, +멱등(idempotent) 메서드만 재시도한다. POST/PATCH 는 Idempotency-Key 계약이 정의되지 않았으므로 보수적으로 항상 재시도하지 않는다. exponential random backoff(jitter)는 settings 로 구동된다. diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index 28c5824..bd3d5da 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -1,6 +1,5 @@ plugins { id 'groovy' } dependencies { - implementation project(':domain-core') implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') @@ -12,6 +11,7 @@ dependencies { implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0' implementation 'org.slf4j:slf4j-api' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' } diff --git a/src/adapter/outbound/httpclient/gradle.lockfile b/src/adapter/outbound/httpclient/gradle.lockfile index a7b8689..368c2b3 100644 --- a/src/adapter/outbound/httpclient/gradle.lockfile +++ b/src/adapter/outbound/httpclient/gradle.lockfile @@ -2,8 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor @@ -53,7 +53,7 @@ io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCo io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -71,9 +71,9 @@ org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -121,12 +121,13 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -135,13 +136,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -159,7 +160,7 @@ org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompi org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallDeadlineExceededException.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallDeadlineExceededException.java new file mode 100644 index 0000000..781eb1d --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallDeadlineExceededException.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +/** Raised when the absolute monotonic logical-call deadline wins. */ +public final class OutboundCallDeadlineExceededException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + OutboundCallDeadlineExceededException() { + super("outbound HTTP call deadline exceeded"); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutor.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutor.java new file mode 100644 index 0000000..d0436d2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutor.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.slf4j.MDC; + +/** Runs one blocking logical call on a cancellable virtual thread under an absolute budget. */ +final class OutboundCallExecutor { + + private static final int DEFAULT_MAXIMUM_IN_FLIGHT_CALLS = 128; + + private final OutboundHttpShutdownGuard shutdownGuard; + private final Semaphore admission; + private final Map, Thread> activeTasks = new ConcurrentHashMap<>(); + private final AtomicBoolean accepting = new AtomicBoolean(true); + + OutboundCallExecutor() { + this(new OutboundHttpShutdownGuard(), DEFAULT_MAXIMUM_IN_FLIGHT_CALLS); + } + + OutboundCallExecutor(OutboundHttpShutdownGuard shutdownGuard, int maximumInFlightCalls) { + this.shutdownGuard = Objects.requireNonNull(shutdownGuard, "shutdownGuard must be non-null"); + if (maximumInFlightCalls < 1 || maximumInFlightCalls > 10_000) { + throw new IllegalArgumentException("maximumInFlightCalls must be in 1..10000"); + } + this.admission = new Semaphore(maximumInFlightCalls); + shutdownGuard.registerShutdownAction(this::shutdown); + } + + T execute(CallBudget budget, Supplier operation) { + Objects.requireNonNull(budget, "budget must be non-null"); + Objects.requireNonNull(operation, "operation must be non-null"); + rejectIfShuttingDown(); + if (budget.isExpiredAt(System.nanoTime())) { + throw new OutboundCallDeadlineExceededException(); + } + if (!admission.tryAcquire()) { + throw new RejectedExecutionException("outbound HTTP in-flight capacity is exhausted"); + } + if (!accepting.get() || shutdownGuard.isShuttingDown()) { + admission.release(); + throw cancellation("outbound HTTP client is shutting down"); + } + + Map callerMdc = MDC.getCopyOfContextMap(); + FutureTask task = + new FutureTask<>( + () -> { + installMdc(callerMdc); + try { + if (budget.isExpiredAt(System.nanoTime())) { + throw new OutboundCallDeadlineExceededException(); + } + return operation.get(); + } finally { + MDC.clear(); + } + }); + Thread worker = + Thread.ofVirtual() + .name("outbound-http-call") + .unstarted( + () -> { + try { + task.run(); + } finally { + activeTasks.remove(task); + admission.release(); + } + }); + activeTasks.put(task, worker); + if (!accepting.get() || shutdownGuard.isShuttingDown()) { + task.cancel(false); + } + try { + worker.start(); + } catch (RuntimeException | Error startFailure) { + activeTasks.remove(task); + admission.release(); + throw startFailure; + } + + long remaining = budget.remainingNanosAt(System.nanoTime()); + if (remaining == 0) { + task.cancel(true); + throw new OutboundCallDeadlineExceededException(); + } + try { + return task.get(remaining, TimeUnit.NANOSECONDS); + } catch (TimeoutException exception) { + task.cancel(true); + throw new OutboundCallDeadlineExceededException(); + } catch (InterruptedException exception) { + task.cancel(true); + Thread.currentThread().interrupt(); + CancellationException cancelled = + new CancellationException("outbound HTTP caller thread was interrupted"); + cancelled.initCause(exception); + throw cancelled; + } catch (ExecutionException exception) { + throw propagate(exception.getCause()); + } + } + + private void shutdown() { + accepting.set(false); + activeTasks.keySet().forEach(task -> task.cancel(true)); + } + + private void rejectIfShuttingDown() { + if (!accepting.get() || shutdownGuard.isShuttingDown()) { + throw cancellation("outbound HTTP client is shutting down"); + } + } + + private static CancellationException cancellation(String message) { + return new CancellationException(message); + } + + private static void installMdc(Map context) { + if (context == null || context.isEmpty()) { + MDC.clear(); + } else { + MDC.setContextMap(context); + } + } + + private static RuntimeException propagate(Throwable failure) { + if (failure instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (failure instanceof Error error) { + throw error; + } + return new IllegalStateException("outbound HTTP worker failed", failure); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java index 89ea616..ccb7aab 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java @@ -53,6 +53,18 @@ final class OutboundHttpCallObserver { return rejected; } + /** Maps local admission saturation without pretending that a network connection was attempted. */ + DependencyFailureException rejectCapacity(Throwable cause) { + DependencyFailureException rejected = + new DependencyFailureException( + OperationalError.DEPENDENCY_CIRCUIT_OPEN, + dependencyName, + "outbound HTTP local in-flight capacity is exhausted before send", + cause); + logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected); + return rejected; + } + private static String outcomeFor(DependencyFailureException dfe) { return switch ((OperationalError) dfe.errorCode()) { case DEPENDENCY_TIMEOUT -> "TIMEOUT"; diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java index 73919af..33260c7 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java @@ -3,15 +3,21 @@ package dev.caskeleton.adapter.outbound.httpclient; import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import dev.caskeleton.application.outbound.CallBudget; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.retry.Retry; import java.io.InputStream; -import java.time.Instant; +import java.nio.charset.StandardCharsets; +import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.function.Supplier; import org.springframework.http.HttpMethod; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; /** * Baseline outbound HTTP client for a single named upstream dependency. Created via the {@code @@ -27,6 +33,7 @@ public final class OutboundHttpClient { private final OutboundHttpResilience resilience; private final OutboundRetryPolicy retryPolicy; private final OutboundHttpCallObserver observer; + private final OutboundCallExecutor callExecutor; private final RestClient bufferedClient; private final RestClient streamingClient; @@ -46,6 +53,7 @@ public final class OutboundHttpClient { this.resilience = resilience; this.retryPolicy = retryPolicy; this.observer = new OutboundHttpCallObserver(dependencyName, errorMapper, logger); + this.callExecutor = new OutboundCallExecutor(shutdownGuard, settings.maximumInFlightCalls()); var clients = OutboundHttpRestClientFactory.create(dependencyName, baseUrl, settings); this.bufferedClient = clients.buffered(); @@ -78,7 +86,12 @@ public final class OutboundHttpClient { /** Shortcut: GET with buffered deserialization. */ public T get(String uri, Class responseType) { - return exchange(HttpMethod.GET, uri, null, responseType); + return get(uri, responseType, CallBudget.fromNow(settings.globalCallTimeout())); + } + + /** GET bounded by the intersection of caller and configured logical-call budgets. */ + public T get(String uri, Class responseType, CallBudget budget) { + return exchange(HttpMethod.GET, uri, null, responseType, budget); } /** @@ -86,42 +99,39 @@ public final class OutboundHttpClient { * propagates unclassified (usage-contract violation — large responses must use {@link #stream}). */ public T exchange(HttpMethod method, String uri, Object requestBody, Class responseType) { + return exchange( + method, uri, requestBody, responseType, CallBudget.fromNow(settings.globalCallTimeout())); + } + + /** Buffered exchange bounded by one absolute monotonic logical-call deadline. */ + public T exchange( + HttpMethod method, + String uri, + Object requestBody, + Class responseType, + CallBudget callerBudget) { + OutboundHttpRestClientFactory.validateRelativeTarget(uri); + Objects.requireNonNull(method, "method must be non-null"); + Objects.requireNonNull(responseType, "responseType must be non-null"); + Objects.requireNonNull(callerBudget, "callerBudget must be non-null"); if (shutdownGuard.isShuttingDown()) { throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast (D8)"); } - Instant deadline = Instant.now().plus(settings.globalCallTimeout()); - retryPolicy.beginCall(method, deadline); - - // Track attempt count for logging — declared outside try so catch can read it. - int[] attemptCount = {0}; + CallBudget effectiveBudget = + callerBudget.intersect(CallBudget.fromNow(settings.globalCallTimeout())); + AtomicInteger attemptCount = new AtomicInteger(); long startNs = System.nanoTime(); try { - Supplier supplier = buildSupplier(method, uri, requestBody, responseType); - - // Retry OUTSIDE the CB so each attempt is independently CB-counted - // (inside the CB, all retries would count as a single CB call). - Optional cb = resilience.circuitBreakerFor(dependencyName); - Optional retry = resilience.retryFor(dependencyName); - - Supplier countingSupplier = - () -> { - attemptCount[0]++; - return supplier.get(); - }; - - Supplier decorated = countingSupplier; - if (retry.isPresent()) { - decorated = Retry.decorateSupplier(retry.get(), decorated); - } - if (cb.isPresent()) { - decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated); - } - - T result = decorated.get(); + T result = + callExecutor.execute( + effectiveBudget, + () -> + executeBuffered( + method, uri, requestBody, responseType, effectiveBudget, attemptCount)); // retryAttempt = attemptCount - 1 (0 means the first attempt succeeded). - observer.recordSuccess(startNs, Math.max(0, attemptCount[0] - 1)); + observer.recordSuccess(startNs, Math.max(0, attemptCount.get() - 1)); return result; } catch (OutboundResponseSizeExceededException sizeEx) { @@ -129,11 +139,14 @@ public final class OutboundHttpClient { // failure). throw sizeEx; - } catch (Throwable t) { - throw observer.recordFailure(t, startNs, Math.max(0, attemptCount[0] - 1)); + } catch (CancellationException cancelled) { + throw cancelled; - } finally { - retryPolicy.endCall(); + } catch (RejectedExecutionException rejected) { + throw observer.rejectCapacity(rejected); + + } catch (Throwable t) { + throw observer.recordFailure(t, startNs, Math.max(0, attemptCount.get() - 1)); } } @@ -142,27 +155,98 @@ public final class OutboundHttpClient { * (delivered bytes are lost and the server may not support resending). */ public T stream(HttpMethod method, String uri, Function reader) { + return stream(method, uri, reader, CallBudget.fromNow(settings.globalCallTimeout())); + } + + /** Streaming exchange bounded by the caller/configured logical-call deadline intersection. */ + public T stream( + HttpMethod method, String uri, Function reader, CallBudget callerBudget) { + OutboundHttpRestClientFactory.validateRelativeTarget(uri); + Objects.requireNonNull(method, "method must be non-null"); + Objects.requireNonNull(reader, "reader must be non-null"); + Objects.requireNonNull(callerBudget, "callerBudget must be non-null"); if (shutdownGuard.isShuttingDown()) { throw observer.rejectShutdown( "shutdown in progress — outbound stream call rejected fail-fast (D8)"); } + CallBudget effectiveBudget = + callerBudget.intersect(CallBudget.fromNow(settings.globalCallTimeout())); long startNs = System.nanoTime(); try { T result = - streamingClient - .method(method) - .uri(uri) - .exchange((req, res) -> reader.apply(res.getBody())); + callExecutor.execute( + effectiveBudget, + () -> + streamingClient + .method(method) + .uri(uri) + .exchange( + (request, response) -> { + if (!response.getStatusCode().is2xxSuccessful()) { + try (InputStream ignored = response.getBody()) { + // Discard without exposing the upstream error body. + } + throw new RestClientResponseException( + "upstream HTTP status rejected before streaming body delivery", + response.getStatusCode(), + response.getStatusText(), + null, + new byte[0], + StandardCharsets.UTF_8); + } + return reader.apply(response.getBody()); + })); observer.recordSuccess(startNs, 0); return result; + } catch (CancellationException cancelled) { + throw cancelled; + } catch (RejectedExecutionException rejected) { + throw observer.rejectCapacity(rejected); } catch (Throwable t) { throw observer.recordFailure(t, startNs, 0); } } + private T executeBuffered( + HttpMethod method, + String uri, + Object requestBody, + Class responseType, + CallBudget budget, + AtomicInteger attemptCount) { + retryPolicy.beginCall(method, budget); + try { + Supplier supplier = buildSupplier(method, uri, requestBody, responseType); + Optional circuitBreaker = resilience.circuitBreakerFor(dependencyName); + Optional retry = resilience.retryFor(dependencyName); + Supplier physicalAttempt = + () -> { + attemptCount.incrementAndGet(); + return supplier.get(); + }; + if (circuitBreaker.isPresent()) { + physicalAttempt = CircuitBreaker.decorateSupplier(circuitBreaker.get(), physicalAttempt); + } + Supplier circuitProtectedAttempt = physicalAttempt; + Supplier decorated = + () -> { + if (budget.isExpiredAt(System.nanoTime())) { + throw new OutboundCallDeadlineExceededException(); + } + return circuitProtectedAttempt.get(); + }; + if (retry.isPresent()) { + decorated = Retry.decorateSupplier(retry.get(), decorated); + } + return decorated.get(); + } finally { + retryPolicy.endCall(); + } + } + private Supplier buildSupplier( HttpMethod method, String uri, Object requestBody, Class responseType) { return () -> { diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java index 12f38f9..fa8fcd2 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java @@ -1,6 +1,8 @@ package dev.caskeleton.adapter.outbound.httpclient; +import java.net.URI; import java.net.http.HttpClient; +import java.util.Locale; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; @@ -17,8 +19,12 @@ final class OutboundHttpRestClientFactory { record Clients(RestClient buffered, RestClient streaming) {} static Clients create(String dependencyName, String baseUrl, OutboundHttpSettings settings) { + validateBaseUrl(baseUrl); HttpClient httpClient = - HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build(); + HttpClient.newBuilder() + .connectTimeout(settings.connectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); requestFactory.setReadTimeout(settings.readTimeout()); @@ -43,4 +49,58 @@ final class OutboundHttpRestClientFactory { return new Clients(buffered, streaming); } + + static void validateRelativeTarget(String target) { + if (target == null + || !target.startsWith("/") + || target.startsWith("//") + || target.indexOf('\\') >= 0 + || target.indexOf('#') >= 0 + || target.chars().anyMatch(character -> Character.isISOControl(character))) { + throw new IllegalArgumentException( + "legacy outbound HTTP request target must be a relative path"); + } + URI parsed; + try { + parsed = URI.create(target); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "legacy outbound HTTP request target must be a valid relative path", exception); + } + String rawPath = parsed.getRawPath(); + String lowerPath = rawPath == null ? "" : rawPath.toLowerCase(Locale.ROOT); + if (parsed.isAbsolute() + || parsed.getRawAuthority() != null + || !parsed.normalize().getRawPath().equals(rawPath) + || lowerPath.contains("%2e") + || lowerPath.contains("%2f") + || lowerPath.contains("%5c")) { + throw new IllegalArgumentException( + "legacy outbound HTTP request target must be an unambiguous relative path"); + } + } + + private static void validateBaseUrl(String baseUrl) { + URI parsed; + try { + parsed = URI.create(baseUrl); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("legacy outbound HTTP base URL is invalid", exception); + } + String scheme = parsed.getScheme(); + if (!parsed.isAbsolute() + || scheme == null + || parsed.getHost() == null + || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + || parsed.getRawUserInfo() != null + || parsed.getRawQuery() != null + || parsed.getRawFragment() != null + || parsed.getRawPath().indexOf('\\') >= 0 + || parsed.getRawPath().chars().anyMatch(character -> Character.isISOControl(character)) + || !parsed.normalize().getRawPath().equals(parsed.getRawPath())) { + throw new IllegalArgumentException( + "legacy outbound HTTP base URL must be a fixed http(s) authority without " + + "user-info, query, fragment, or ambiguous path"); + } + } } diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java index 146e304..5ca57d6 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java @@ -13,6 +13,7 @@ import org.springframework.util.unit.DataSize; * @param connectTimeout TCP connect timeout; must be positive * @param readTimeout socket read timeout; must be positive * @param globalCallTimeout end-to-end deadline budget per call including retries; must be positive + * @param maximumInFlightCalls maximum live logical-call workers owned by one client * @param retryEnabled whether the Resilience4j retry decorator is active * @param circuitBreakerEnabled whether the Resilience4j circuit-breaker decorator is active * @param responseSizeLimit max in-memory response body size; null defaults to 10 MB; zero/negative @@ -25,6 +26,7 @@ public record OutboundHttpSettings( Duration connectTimeout, Duration readTimeout, Duration globalCallTimeout, + Integer maximumInFlightCalls, boolean retryEnabled, boolean circuitBreakerEnabled, DataSize responseSizeLimit, @@ -34,6 +36,8 @@ public record OutboundHttpSettings( /** Registry default for {@code APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT}. */ private static final DataSize DEFAULT_RESPONSE_SIZE_LIMIT = DataSize.ofMegabytes(10); + private static final Duration MAXIMUM_GLOBAL_CALL_TIMEOUT = Duration.ofDays(365); + private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3; private static final Duration DEFAULT_RETRY_INITIAL_BACKOFF = Duration.ofMillis(100); private static final double DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0; @@ -56,10 +60,20 @@ public record OutboundHttpSettings( "APP_OUTBOUND_HTTP_READ_TIMEOUT (app.outbound.http.read-timeout) must be a " + "positive duration (spring_duration_shorthand_non_zero, D5)"); } - if (globalCallTimeout == null || globalCallTimeout.isZero() || globalCallTimeout.isNegative()) { + if (globalCallTimeout == null + || globalCallTimeout.isZero() + || globalCallTimeout.isNegative() + || globalCallTimeout.compareTo(MAXIMUM_GLOBAL_CALL_TIMEOUT) > 0) { throw new IllegalArgumentException( "APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT (app.outbound.http.global-call-timeout) must be a " - + "positive duration (spring_duration_shorthand_non_zero, D5)"); + + "positive duration no greater than 365 days"); + } + if (maximumInFlightCalls == null) { + maximumInFlightCalls = 128; + } else if (maximumInFlightCalls < 1 || maximumInFlightCalls > 10_000) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS " + + "(app.outbound.http.maximum-in-flight-calls) must be in 1..10000"); } if (responseSizeLimit == null) { responseSizeLimit = DEFAULT_RESPONSE_SIZE_LIMIT; @@ -77,6 +91,28 @@ public record OutboundHttpSettings( } } + /** Compatibility constructor preserving the former canonical 8-argument shape. */ + public OutboundHttpSettings( + Duration connectTimeout, + Duration readTimeout, + Duration globalCallTimeout, + boolean retryEnabled, + boolean circuitBreakerEnabled, + DataSize responseSizeLimit, + Retry retry, + CircuitBreaker circuitBreaker) { + this( + connectTimeout, + readTimeout, + globalCallTimeout, + null, + retryEnabled, + circuitBreakerEnabled, + responseSizeLimit, + retry, + circuitBreaker); + } + /** * Secondary constructor: defaults for resilience tuning; preserves the original 6-arg call sites. */ @@ -91,6 +127,7 @@ public record OutboundHttpSettings( connectTimeout, readTimeout, globalCallTimeout, + null, retryEnabled, circuitBreakerEnabled, responseSizeLimit, diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java index d1e8425..b428d43 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java @@ -1,5 +1,7 @@ package dev.caskeleton.adapter.outbound.httpclient; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import org.springframework.context.SmartLifecycle; @@ -12,6 +14,7 @@ public final class OutboundHttpShutdownGuard implements SmartLifecycle { private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + private final Set shutdownActions = ConcurrentHashMap.newKeySet(); @Override public void start() { @@ -22,6 +25,7 @@ public final class OutboundHttpShutdownGuard implements SmartLifecycle { public void stop() { shuttingDown.set(true); running.set(false); + shutdownActions.forEach(Runnable::run); } @Override @@ -46,4 +50,11 @@ public final class OutboundHttpShutdownGuard implements SmartLifecycle { public boolean isShuttingDown() { return shuttingDown.get(); } + + void registerShutdownAction(Runnable action) { + shutdownActions.add(action); + if (shuttingDown.get()) { + action.run(); + } + } } diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java index ea6bc40..f7b7101 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java @@ -1,7 +1,9 @@ package dev.caskeleton.adapter.outbound.httpclient; import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.application.outbound.CallBudget; import dev.caskeleton.shared.error.DependencyFailureException; +import java.time.Duration; import java.time.Instant; import java.util.Set; import org.springframework.http.HttpMethod; @@ -36,8 +38,27 @@ public final class OutboundRetryPolicy { } /** Must be paired with {@link #endCall()} in try/finally. */ + public void beginCall(HttpMethod method, CallBudget budget) { + callContextHolder.set(new CallContext(method, budget.monotonicDeadlineNanos())); + } + + /** + * Compatibility bridge for the legacy tests/facade. New call paths must carry {@link CallBudget} + * directly. + */ + @Deprecated public void beginCall(HttpMethod method, Instant deadline) { - callContextHolder.set(new CallContext(method, deadline)); + long remaining; + try { + remaining = Duration.between(Instant.now(), deadline).toNanos(); + } catch (ArithmeticException exception) { + remaining = Long.MAX_VALUE; + } + long now = System.nanoTime(); + long boundedRemaining = Math.max(0, remaining); + long monotonicDeadline = + boundedRemaining > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + boundedRemaining; + callContextHolder.set(new CallContext(method, monotonicDeadline)); } public void endCall() { @@ -69,8 +90,8 @@ public final class OutboundRetryPolicy { return false; } - return Instant.now().isBefore(ctx.deadline()); + return ctx.monotonicDeadlineNanos() - System.nanoTime() > 0; } - private record CallContext(HttpMethod method, Instant deadline) {} + private record CallContext(HttpMethod method, long monotonicDeadlineNanos) {} } diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java index 02a3b26..4ead7d3 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java @@ -1,5 +1,6 @@ package dev.caskeleton.adapter.outbound.httpclient.diagnostics; +import dev.caskeleton.adapter.outbound.httpclient.OutboundCallDeadlineExceededException; import dev.caskeleton.shared.error.DependencyFailureException; import dev.caskeleton.shared.error.OperationalError; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; @@ -86,7 +87,8 @@ public final class OutboundHttpErrorMapper { + ")", failure); } - if (current instanceof HttpTimeoutException + if (current instanceof OutboundCallDeadlineExceededException + || current instanceof HttpTimeoutException || current instanceof SocketTimeoutException || current instanceof TimeoutException) { return new DependencyFailureException( diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/FixedHttpDestination.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/FixedHttpDestination.java new file mode 100644 index 0000000..21d7c7b --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/FixedHttpDestination.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +import java.net.URI; +import java.util.Locale; +import java.util.Objects; + +/** Validated fixed base authority for the internal operation kernel. */ +public final class FixedHttpDestination { + + private final HttpDestinationId destinationId; + private final URI baseUri; + private final boolean requireHttps; + + public FixedHttpDestination(HttpDestinationId destinationId, URI baseUri, boolean requireHttps) { + this.destinationId = Objects.requireNonNull(destinationId, "destinationId must be non-null"); + Objects.requireNonNull(baseUri, "baseUri must be non-null"); + String scheme = baseUri.getScheme(); + if (!baseUri.isAbsolute() || scheme == null || baseUri.getHost() == null) { + throw new IllegalArgumentException("HTTP base URI must be absolute with a host"); + } + scheme = scheme.toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException("HTTP base URI scheme must be http or https"); + } + if (requireHttps && !"https".equals(scheme)) { + throw new IllegalArgumentException("HTTP destination requires https"); + } + if (baseUri.getRawUserInfo() != null) { + throw new IllegalArgumentException("HTTP base URI must not contain user-info"); + } + if (baseUri.getRawQuery() != null) { + throw new IllegalArgumentException("HTTP base URI must not contain a query"); + } + if (baseUri.getRawFragment() != null) { + throw new IllegalArgumentException("HTTP base URI must not contain a fragment"); + } + String path = baseUri.getRawPath(); + if (path == null) { + path = ""; + } + if (path.indexOf('\\') >= 0 + || path.indexOf('%') >= 0 + || path.chars().anyMatch(character -> Character.isISOControl(character)) + || !URI.create(path.isEmpty() ? "/" : path) + .normalize() + .getPath() + .equals(path.isEmpty() ? "/" : path)) { + throw new IllegalArgumentException("HTTP base URI contains an ambiguous path"); + } + this.baseUri = URI.create(scheme + "://" + baseUri.getRawAuthority() + path); + this.requireHttps = requireHttps; + } + + HttpDestinationId destinationId() { + return destinationId; + } + + URI baseUri() { + return baseUri; + } + + boolean requireHttps() { + return requireHttps; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpDestinationId.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpDestinationId.java new file mode 100644 index 0000000..46e7f84 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpDestinationId.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +/** Stable low-cardinality destination registry identifier; never a host or caller value. */ +public record HttpDestinationId(String value) { + + public HttpDestinationId { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException("HTTP destination id must match [a-z][a-z0-9-]{0,62}"); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java new file mode 100644 index 0000000..48ae59c --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Immutable startup-time operation catalog; runtime registration is deliberately absent. */ +public final class HttpOperationCatalog { + + private final Map descriptors; + + public HttpOperationCatalog(Collection descriptors) { + Objects.requireNonNull(descriptors, "descriptors must be non-null"); + Map indexed = new LinkedHashMap<>(); + for (HttpOperationDescriptor descriptor : descriptors) { + Objects.requireNonNull(descriptor, "descriptor must be non-null"); + if (indexed.putIfAbsent(descriptor.operationId(), descriptor) != null) { + throw new IllegalArgumentException( + "duplicate HTTP operation id: " + descriptor.operationId().value()); + } + } + this.descriptors = Map.copyOf(indexed); + } + + HttpOperationDescriptor require(HttpOperationId operationId) { + HttpOperationDescriptor descriptor = descriptors.get(operationId); + if (descriptor == null) { + throw new IllegalArgumentException("unregistered HTTP operation: " + operationId.value()); + } + return descriptor; + } + + public Collection descriptors() { + return descriptors.values(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationDescriptor.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationDescriptor.java new file mode 100644 index 0000000..7ec74c7 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationDescriptor.java @@ -0,0 +1,182 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +import java.util.Objects; +import java.util.Set; + +/** Immutable upper bound for one registered HTTP operation. Callers cannot override its policy. */ +public final class HttpOperationDescriptor { + + private static final long MAXIMUM_BUFFERED_RESPONSE_BYTES = 1_073_741_824; + + private final HttpOperationId operationId; + private final HttpDestinationId destinationId; + private final int policyRevision; + private final Method method; + private final String routeTemplate; + private final OperationSemantics semantics; + private final RequestMode requestMode; + private final ResponseMode responseMode; + private final Set successStatuses; + private final int ordinaryMaximumRetries; + private final int maximumPhysicalAttempts; + private final long maximumResponseBytes; + + public HttpOperationDescriptor( + HttpOperationId operationId, + HttpDestinationId destinationId, + int policyRevision, + Method method, + String routeTemplate, + OperationSemantics semantics, + RequestMode requestMode, + ResponseMode responseMode, + Set successStatuses, + int ordinaryMaximumRetries, + int maximumPhysicalAttempts, + long maximumResponseBytes) { + this.operationId = Objects.requireNonNull(operationId, "operationId must be non-null"); + this.destinationId = Objects.requireNonNull(destinationId, "destinationId must be non-null"); + this.method = Objects.requireNonNull(method, "method must be non-null"); + this.semantics = Objects.requireNonNull(semantics, "semantics must be non-null"); + this.requestMode = Objects.requireNonNull(requestMode, "requestMode must be non-null"); + this.responseMode = Objects.requireNonNull(responseMode, "responseMode must be non-null"); + if (policyRevision < 1) { + throw new IllegalArgumentException("HTTP operation policyRevision must be >= 1"); + } + this.policyRevision = policyRevision; + validateRelativeRoute(routeTemplate); + this.routeTemplate = routeTemplate; + this.successStatuses = Set.copyOf(successStatuses); + if (this.successStatuses.isEmpty() + || this.successStatuses.stream().anyMatch(status -> status < 200 || status > 299)) { + throw new IllegalArgumentException( + "HTTP operation success statuses must be a non-empty 2xx set"); + } + if (ordinaryMaximumRetries < 0 || ordinaryMaximumRetries > 5) { + throw new IllegalArgumentException("ordinaryMaximumRetries must be in 0..5"); + } + if (maximumPhysicalAttempts < 1 + || maximumPhysicalAttempts > 8 + || maximumPhysicalAttempts < ordinaryMaximumRetries + 1) { + throw new IllegalArgumentException( + "maximumPhysicalAttempts must cover the initial request and ordinary retries"); + } + this.ordinaryMaximumRetries = ordinaryMaximumRetries; + this.maximumPhysicalAttempts = maximumPhysicalAttempts; + if (maximumResponseBytes < 1 || maximumResponseBytes > MAXIMUM_BUFFERED_RESPONSE_BYTES) { + throw new IllegalArgumentException("maximumResponseBytes exceeds the buffered hard bound"); + } + this.maximumResponseBytes = maximumResponseBytes; + if (ordinaryMaximumRetries > 0 && requestMode == RequestMode.SINGLE_USE_STREAM) { + throw new IllegalArgumentException( + "automatic retry requires a replayable absent, buffered, or reopenable request body"); + } + if (semantics == OperationSemantics.NON_RETRYABLE_MUTATION + && (ordinaryMaximumRetries != 0 || maximumPhysicalAttempts != 1)) { + throw new IllegalArgumentException( + "non-retryable mutation must allow exactly one physical attempt"); + } + if (semantics == OperationSemantics.SAFE_READ + && method != Method.GET + && method != Method.HEAD) { + throw new IllegalArgumentException("safe-read operations must use GET or HEAD"); + } + } + + HttpOperationId operationId() { + return operationId; + } + + HttpDestinationId destinationId() { + return destinationId; + } + + int policyRevision() { + return policyRevision; + } + + Method method() { + return method; + } + + String routeTemplate() { + return routeTemplate; + } + + OperationSemantics semantics() { + return semantics; + } + + RequestMode requestMode() { + return requestMode; + } + + ResponseMode responseMode() { + return responseMode; + } + + Set successStatuses() { + return successStatuses; + } + + int ordinaryMaximumRetries() { + return ordinaryMaximumRetries; + } + + int maximumPhysicalAttempts() { + return maximumPhysicalAttempts; + } + + long maximumResponseBytes() { + return maximumResponseBytes; + } + + private static void validateRelativeRoute(String route) { + if (route == null + || !route.startsWith("/") + || route.startsWith("//") + || route.contains("://") + || route.indexOf('?') >= 0 + || route.indexOf('#') >= 0 + || route.indexOf('\\') >= 0 + || route.indexOf('%') >= 0 + || route.chars().anyMatch(character -> Character.isISOControl(character))) { + throw new IllegalArgumentException( + "HTTP operation route must be an unambiguous relative path template"); + } + for (String segment : route.split("/", -1)) { + if (".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException("HTTP operation route must not contain dot segments"); + } + } + } + + public enum Method { + GET, + HEAD, + POST, + PUT, + PATCH, + DELETE + } + + public enum OperationSemantics { + SAFE_READ, + IDEMPOTENT_MUTATION, + KEYED_MUTATION, + NON_RETRYABLE_MUTATION + } + + public enum RequestMode { + NONE, + BUFFERED, + REOPENABLE_STREAM, + SINGLE_USE_STREAM + } + + public enum ResponseMode { + BODILESS, + BUFFERED, + STREAM_CALLBACK + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationId.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationId.java new file mode 100644 index 0000000..6da0793 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationId.java @@ -0,0 +1,14 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +/** Stable versioned operation registry identifier suitable for metrics and policy joins. */ +public record HttpOperationId(String value) { + + public HttpOperationId { + if (value == null + || value.length() > 128 + || !value.matches("[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+\\.v[1-9][0-9]*")) { + throw new IllegalArgumentException( + "HTTP operation id must be bounded lowercase dot notation ending in .vN"); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpTargetBuilder.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpTargetBuilder.java new file mode 100644 index 0000000..fa708e4 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpTargetBuilder.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Resolves an internal registered relative route against one fixed destination. */ +public final class HttpTargetBuilder { + + private static final Pattern VARIABLE = Pattern.compile("\\{([a-z][a-zA-Z0-9]{0,31})}"); + private static final char[] HEX = "0123456789ABCDEF".toCharArray(); + + private HttpTargetBuilder() {} + + public static URI resolve( + FixedHttpDestination destination, + HttpOperationDescriptor operation, + Map pathVariables) { + Objects.requireNonNull(destination, "destination must be non-null"); + Objects.requireNonNull(operation, "operation must be non-null"); + Objects.requireNonNull(pathVariables, "pathVariables must be non-null"); + if (!destination.destinationId().equals(operation.destinationId())) { + throw new IllegalArgumentException("HTTP operation destination does not match binding"); + } + + Set required = new HashSet<>(); + Matcher matcher = VARIABLE.matcher(operation.routeTemplate()); + StringBuilder route = new StringBuilder(); + while (matcher.find()) { + String variable = matcher.group(1); + if (!required.add(variable)) { + throw new IllegalArgumentException("duplicate HTTP path variable: " + variable); + } + String value = pathVariables.get(variable); + if (value == null) { + throw new IllegalArgumentException("missing HTTP path variable: " + variable); + } + matcher.appendReplacement(route, Matcher.quoteReplacement(encodeSegment(value))); + } + matcher.appendTail(route); + if (!required.equals(pathVariables.keySet())) { + throw new IllegalArgumentException("unknown HTTP path variable supplied"); + } + + String basePath = destination.baseUri().getRawPath(); + if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { + basePath = ""; + } else if (basePath.endsWith("/")) { + basePath = basePath.substring(0, basePath.length() - 1); + } + String target = + destination.baseUri().getScheme() + + "://" + + destination.baseUri().getRawAuthority() + + basePath + + route; + return URI.create(target); + } + + private static String encodeSegment(String value) { + if (value.isBlank() + || ".".equals(value) + || "..".equals(value) + || value.indexOf('/') >= 0 + || value.indexOf('\\') >= 0 + || value.indexOf('%') >= 0 + || value.chars().anyMatch(character -> Character.isISOControl(character))) { + throw new IllegalArgumentException("HTTP path variable must be exactly one raw segment"); + } + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + StringBuilder encoded = new StringBuilder(bytes.length); + for (byte current : bytes) { + int unsigned = current & 0xff; + if (isUnreserved(unsigned)) { + encoded.append((char) unsigned); + } else { + encoded.append('%'); + encoded.append(HEX[unsigned >>> 4]); + encoded.append(HEX[unsigned & 0x0f]); + } + } + return encoded.toString(); + } + + private static boolean isUnreserved(int value) { + return (value >= 'a' && value <= 'z') + || (value >= 'A' && value <= 'Z') + || (value >= '0' && value <= '9') + || value == '-' + || value == '.' + || value == '_' + || value == '~'; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutorTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutorTest.java new file mode 100644 index 0000000..2f8325c --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundCallExecutorTest.java @@ -0,0 +1,206 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +class OutboundCallExecutorTest { + + private final OutboundCallExecutor executor = new OutboundCallExecutor(); + + @Test + void expiredBudgetDoesNotStartWork() { + AtomicBoolean started = new AtomicBoolean(); + CallBudget expired = CallBudget.after(System.nanoTime() - 2, Duration.ofNanos(1)); + + assertThatThrownBy(() -> executor.execute(expired, () -> started.getAndSet(true))) + .isInstanceOf(OutboundCallDeadlineExceededException.class); + assertThat(started).isFalse(); + } + + @Test + void interruptsRunningVirtualThreadWhenDeadlineWins() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + + assertThatThrownBy( + () -> + executor.execute( + CallBudget.fromNow(Duration.ofMillis(80)), + () -> { + started.countDown(); + try { + Thread.sleep(Duration.ofSeconds(5)); + } catch (InterruptedException exception) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + return "late"; + })) + .isInstanceOf(OutboundCallDeadlineExceededException.class); + + assertThat(started.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(interrupted.await(1, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void returnsCompletedResultBeforeDeadline() { + String result = executor.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> "completed"); + + assertThat(result).isEqualTo("completed"); + } + + @Test + void propagatesAndCleansCallerMdcInTheWorker() { + MDC.put("trace_id", "trace-1"); + try { + String trace = + executor.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> MDC.get("trace_id")); + + assertThat(trace).isEqualTo("trace-1"); + assertThat(MDC.get("trace_id")).isEqualTo("trace-1"); + } finally { + MDC.clear(); + } + } + + @Test + void callerInterruptionRemainsCancellationAndPreservesTheInterruptFlag() throws Exception { + CountDownLatch operationStarted = new CountDownLatch(1); + CountDownLatch operationInterrupted = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean callerInterruptPreserved = new AtomicBoolean(); + Thread caller = + Thread.ofPlatform() + .start( + () -> { + try { + executor.execute( + CallBudget.fromNow(Duration.ofSeconds(5)), + () -> { + operationStarted.countDown(); + try { + Thread.sleep(Duration.ofSeconds(5)); + } catch (InterruptedException exception) { + operationInterrupted.countDown(); + Thread.currentThread().interrupt(); + } + return "late"; + }); + } catch (Throwable throwable) { + failure.set(throwable); + callerInterruptPreserved.set(Thread.currentThread().isInterrupted()); + } + }); + + assertThat(operationStarted.await(1, TimeUnit.SECONDS)).isTrue(); + caller.interrupt(); + caller.join(1_000); + + assertThat(caller.isAlive()).isFalse(); + assertThat(failure.get()).isInstanceOf(CancellationException.class); + assertThat(callerInterruptPreserved).isTrue(); + assertThat(operationInterrupted.await(1, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void nonCooperativeTimedOutWorkerKeepsItsBoundedAdmissionUntilItActuallyStops() throws Exception { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + OutboundCallExecutor bounded = new OutboundCallExecutor(guard, 1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch exited = new CountDownLatch(1); + AtomicReference firstFailure = new AtomicReference<>(); + Thread firstCaller = + Thread.ofPlatform() + .start( + () -> { + try { + bounded.execute( + CallBudget.fromNow(Duration.ofMillis(80)), + () -> { + started.countDown(); + try { + while (release.getCount() > 0) { + try { + release.await(); + } catch (InterruptedException ignored) { + // Deliberately non-cooperative to prove the admission stays owned. + } + } + return "released"; + } finally { + exited.countDown(); + } + }); + } catch (Throwable throwable) { + firstFailure.set(throwable); + } + }); + + assertThat(started.await(1, TimeUnit.SECONDS)).isTrue(); + firstCaller.join(1_000); + assertThat(firstFailure.get()).isInstanceOf(OutboundCallDeadlineExceededException.class); + + assertThatThrownBy( + () -> + bounded.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> "must-not-start")) + .isInstanceOf(RejectedExecutionException.class); + + release.countDown(); + assertThat(exited.await(1, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void shutdownCancelsInFlightWorkAndRejectsNewStarts() throws Exception { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + OutboundCallExecutor guarded = new OutboundCallExecutor(guard, 1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread caller = + Thread.ofPlatform() + .start( + () -> { + try { + guarded.execute( + CallBudget.fromNow(Duration.ofSeconds(5)), + () -> { + started.countDown(); + try { + Thread.sleep(Duration.ofSeconds(5)); + } catch (InterruptedException exception) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + return "late"; + }); + } catch (Throwable throwable) { + failure.set(throwable); + } + }); + + assertThat(started.await(1, TimeUnit.SECONDS)).isTrue(); + guard.stop(); + caller.join(1_000); + + assertThat(failure.get()).isInstanceOf(CancellationException.class); + assertThat(interrupted.await(1, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy( + () -> + guarded.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> "must-not-start")) + .isInstanceOf(CancellationException.class); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java index 4e57ae0..8cbd2cf 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java @@ -12,6 +12,7 @@ import dev.caskeleton.shared.error.OperationalError; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import java.net.SocketTimeoutException; +import java.util.concurrent.RejectedExecutionException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; @@ -164,6 +165,17 @@ class OutboundHttpCallObserverTest { assertThat(event.getFormattedMessage()).contains("outcome=\"REJECTED\""); } + @Test + void capacityRejectionIsLoggedAsRejectedWithoutAConnectFailureClassification() { + DependencyFailureException dfe = + observer.rejectCapacity(new RejectedExecutionException("capacity")); + + assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN); + assertThat(dfe.getMessage()).contains("before send"); + assertThat(logAppender.list) + .anyMatch(event -> event.getFormattedMessage().contains("outcome=\"REJECTED\"")); + } + @Test void rejectShutdownLogsDuration0AndRetryAttempt0() { observer.rejectShutdown("shutdown test"); diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientDeadlineTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientDeadlineTest.java new file mode 100644 index 0000000..6beb32e --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientDeadlineTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import com.sun.net.httpserver.HttpServer; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.RetryRegistry; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.util.unit.DataSize; + +class OutboundHttpClientDeadlineTest { + + private HttpServer server; + private String baseUrl; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.start(); + baseUrl = "http://localhost:" + server.getAddress().getPort(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void shorterCallerBudgetBoundsBlockingRead() { + server.createContext( + "/slow", + exchange -> { + try { + Thread.sleep(2_000); + exchange.sendResponseHeaders(200, -1); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + OutboundHttpSettings settings = settings(false, false, Duration.ofMillis(100)); + OutboundHttpClient client = client(settings); + long started = System.nanoTime(); + + DependencyFailureException failure = + catchThrowableOfType( + DependencyFailureException.class, + () -> client.get("/slow", String.class, CallBudget.fromNow(Duration.ofMillis(100)))); + + assertThat(failure.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT); + assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(1)); + } + + @Test + void deadlineDuringBackoffPreventsSecondPhysicalAttempt() { + AtomicInteger serverCalls = new AtomicInteger(); + server.createContext( + "/retry", + exchange -> { + serverCalls.incrementAndGet(); + exchange.sendResponseHeaders(500, -1); + exchange.close(); + }); + OutboundHttpSettings settings = settings(true, true, Duration.ofSeconds(1)); + CircuitBreakerRegistry circuitBreakers = CircuitBreakerRegistry.ofDefaults(); + OutboundHttpClient client = client(settings, circuitBreakers); + + DependencyFailureException failure = + catchThrowableOfType( + DependencyFailureException.class, + () -> client.get("/retry", String.class, CallBudget.fromNow(Duration.ofMillis(400)))); + + assertThat(failure.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT); + assertThat(serverCalls).hasValue(1); + assertThat(circuitBreakers.circuitBreaker("deadline-dep").getMetrics().getNumberOfFailedCalls()) + .isEqualTo(1); + } + + private OutboundHttpClient client(OutboundHttpSettings settings) { + return client(settings, CircuitBreakerRegistry.ofDefaults()); + } + + private OutboundHttpClient client( + OutboundHttpSettings settings, CircuitBreakerRegistry circuitBreakers) { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + OutboundHttpErrorMapper mapper = new OutboundHttpErrorMapper(); + OutboundRetryPolicy retryPolicy = new OutboundRetryPolicy(settings, guard, mapper); + OutboundHttpResilience resilience = + new OutboundHttpResilience( + settings, retryPolicy, RetryRegistry.ofDefaults(), circuitBreakers); + return OutboundHttpClient.baseline( + "deadline-dep", + baseUrl, + settings, + guard, + resilience, + retryPolicy, + mapper, + new OutboundHttpDependencyLogger()); + } + + private static OutboundHttpSettings settings( + boolean retry, boolean circuitBreaker, Duration retryBackoff) { + return new OutboundHttpSettings( + Duration.ofSeconds(1), + Duration.ofSeconds(5), + Duration.ofSeconds(5), + retry, + circuitBreaker, + DataSize.ofMegabytes(1), + new OutboundHttpSettings.Retry(3, retryBackoff, 1.0), + null); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java new file mode 100644 index 0000000..0f393fb --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sun.net.httpserver.HttpServer; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import dev.caskeleton.shared.error.DependencyFailureException; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.RetryRegistry; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.util.unit.DataSize; + +class OutboundHttpClientSafetyRegressionTest { + + private HttpServer server; + private String baseUrl; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.start(); + baseUrl = "http://localhost:" + server.getAddress().getPort(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void streamingRejectsErrorStatusBeforeExposingTheBody() { + AtomicInteger readerCalls = new AtomicInteger(); + server.createContext( + "/error-stream", + exchange -> { + byte[] body = "must-not-reach-reader".getBytes(UTF_8); + exchange.sendResponseHeaders(500, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + OutboundHttpClient client = client(settings(false, false)); + + assertThatThrownBy( + () -> + client.stream( + HttpMethod.GET, + "/error-stream", + input -> { + readerCalls.incrementAndGet(); + return "unexpected"; + })) + .isInstanceOf(DependencyFailureException.class); + assertThat(readerCalls).hasValue(0); + } + + @Test + void circuitBreakerCountsEveryPhysicalRetryAttempt() { + AtomicInteger serverCalls = new AtomicInteger(); + server.createContext( + "/always-fails", + exchange -> { + serverCalls.incrementAndGet(); + exchange.sendResponseHeaders(500, -1); + exchange.close(); + }); + OutboundHttpSettings settings = settings(true, true); + OutboundHttpShutdownGuard guard = activeGuard(); + OutboundRetryPolicy retryPolicy = + new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper()); + OutboundHttpResilience resilience = + new OutboundHttpResilience( + settings, retryPolicy, RetryRegistry.ofDefaults(), CircuitBreakerRegistry.ofDefaults()); + OutboundHttpClient client = client(settings, guard, retryPolicy, resilience); + + assertThatThrownBy(() -> client.get("/always-fails", String.class)) + .isInstanceOf(DependencyFailureException.class); + + assertThat(serverCalls).hasValue(3); + assertThat( + resilience + .circuitBreakerFor("test-dep") + .orElseThrow() + .getMetrics() + .getNumberOfFailedCalls()) + .isEqualTo(3); + } + + @Test + void legacyFacadeRejectsAbsoluteRequestTargetsBeforeNetworkAccess() { + OutboundHttpClient client = client(settings(false, false)); + + assertThatThrownBy(() -> client.get("https://evil.example.test/escape", String.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("relative"); + } + + private OutboundHttpClient client(OutboundHttpSettings settings) { + OutboundHttpShutdownGuard guard = activeGuard(); + OutboundRetryPolicy retryPolicy = + new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper()); + OutboundHttpResilience resilience = + new OutboundHttpResilience(settings, retryPolicy, null, null); + return client(settings, guard, retryPolicy, resilience); + } + + private OutboundHttpClient client( + OutboundHttpSettings settings, + OutboundHttpShutdownGuard guard, + OutboundRetryPolicy retryPolicy, + OutboundHttpResilience resilience) { + return OutboundHttpClient.baseline( + "test-dep", + baseUrl, + settings, + guard, + resilience, + retryPolicy, + new OutboundHttpErrorMapper(), + new OutboundHttpDependencyLogger()); + } + + private static OutboundHttpSettings settings(boolean retry, boolean circuitBreaker) { + return new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(5), + retry, + circuitBreaker, + DataSize.ofMegabytes(1)); + } + + private static OutboundHttpShutdownGuard activeGuard() { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + return guard; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java index d34fec3..7486b9e 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java @@ -33,6 +33,7 @@ class OutboundHttpSettingsTest { assertThat(settings.connectTimeout()).isEqualTo(Duration.ofSeconds(2)); assertThat(settings.readTimeout()).isEqualTo(Duration.ofSeconds(5)); assertThat(settings.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(settings.maximumInFlightCalls()).isEqualTo(128); assertThat(settings.retryEnabled()).isFalse(); assertThat(settings.circuitBreakerEnabled()).isFalse(); assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); @@ -146,6 +147,21 @@ class OutboundHttpSettingsTest { .hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT"); } + @Test + void globalCallTimeoutCannotExceedTheCallBudgetMaximum() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofDays(366), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("365 days"); + } + @Test void nullResponseSizeLimitDefaultsTo10MB() { OutboundHttpSettings settings = @@ -204,6 +220,7 @@ class OutboundHttpSettingsTest { "app.outbound.http.connect-timeout=2s", "app.outbound.http.read-timeout=5s", "app.outbound.http.global-call-timeout=10s", + "app.outbound.http.maximum-in-flight-calls=7", "app.outbound.http.retry-enabled=true", "app.outbound.http.circuit-breaker-enabled=false", "app.outbound.http.response-size-limit=10MB") @@ -214,6 +231,7 @@ class OutboundHttpSettingsTest { assertThat(s.connectTimeout()).isEqualTo(Duration.ofSeconds(2)); assertThat(s.readTimeout()).isEqualTo(Duration.ofSeconds(5)); assertThat(s.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(s.maximumInFlightCalls()).isEqualTo(7); assertThat(s.retryEnabled()).isTrue(); assertThat(s.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); }); @@ -279,6 +297,24 @@ class OutboundHttpSettingsTest { .hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS"); } + @Test + void maximumInFlightCallsOutsideTheBoundThrows() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + 0, + false, + false, + DataSize.ofMegabytes(10), + null, + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS"); + } + @Test void retryInitialBackoffZeroThrows() { assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, Duration.ZERO, null)) diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java new file mode 100644 index 0000000..2ef9c69 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class HttpOperationCatalogTest { + + @Test + void catalogResolvesAClosedTypedOperation() { + HttpOperationDescriptor descriptor = safeRead(); + HttpOperationCatalog catalog = new HttpOperationCatalog(List.of(descriptor)); + + assertThat(catalog.require(descriptor.operationId())).isSameAs(descriptor); + assertThat(catalog.descriptors()).containsExactly(descriptor); + } + + @Test + void rejectsDuplicateOperationIds() { + HttpOperationDescriptor descriptor = safeRead(); + + assertThatThrownBy(() -> new HttpOperationCatalog(List.of(descriptor, descriptor))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + } + + @Test + void rejectsUnsafeRouteAndRetryForSingleUseOrNonRetryableMutation() { + assertThatThrownBy( + () -> + descriptor( + "https://evil.example/items/{itemId}", + HttpOperationDescriptor.OperationSemantics.SAFE_READ, + HttpOperationDescriptor.RequestMode.NONE, + 1, + 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("relative"); + + assertThatThrownBy( + () -> + descriptor( + "/v1/items/{itemId}", + HttpOperationDescriptor.OperationSemantics.SAFE_READ, + HttpOperationDescriptor.RequestMode.SINGLE_USE_STREAM, + 1, + 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("replay"); + + assertThatThrownBy( + () -> + descriptor( + "/v1/items/{itemId}", + HttpOperationDescriptor.OperationSemantics.NON_RETRYABLE_MUTATION, + HttpOperationDescriptor.RequestMode.BUFFERED, + 1, + 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-retryable"); + } + + private static HttpOperationDescriptor safeRead() { + return descriptor( + "/v1/items/{itemId}", + HttpOperationDescriptor.OperationSemantics.SAFE_READ, + HttpOperationDescriptor.RequestMode.NONE, + 1, + 2); + } + + private static HttpOperationDescriptor descriptor( + String route, + HttpOperationDescriptor.OperationSemantics semantics, + HttpOperationDescriptor.RequestMode requestMode, + int ordinaryMaxRetries, + int maximumPhysicalAttempts) { + return new HttpOperationDescriptor( + new HttpOperationId("catalog.get-item.v1"), + new HttpDestinationId("partner-catalog"), + 1, + HttpOperationDescriptor.Method.GET, + route, + semantics, + requestMode, + HttpOperationDescriptor.ResponseMode.BUFFERED, + Set.of(200), + ordinaryMaxRetries, + maximumPhysicalAttempts, + 1_048_576); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpTargetBuilderTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpTargetBuilderTest.java new file mode 100644 index 0000000..c1fcfeb --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpTargetBuilderTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.httpclient.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +final class HttpTargetBuilderTest { + + @Test + void resolvesOnlyRegisteredRelativeRouteAndEncodesOneSegmentOnce() { + FixedHttpDestination destination = + new FixedHttpDestination( + new HttpDestinationId("partner-catalog"), + URI.create("https://api.example.test/base"), + true); + + URI target = HttpTargetBuilder.resolve(destination, operation(), Map.of("itemId", "item 42")); + + assertThat(target.toASCIIString()) + .isEqualTo("https://api.example.test/base/v1/items/item%2042"); + } + + @Test + void rejectsDestinationMismatchAndMultiSegmentOrPreEncodedValues() { + FixedHttpDestination destination = + new FixedHttpDestination( + new HttpDestinationId("other-service"), URI.create("https://other.example.test"), true); + + assertThatThrownBy( + () -> HttpTargetBuilder.resolve(destination, operation(), Map.of("itemId", "42"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destination"); + + FixedHttpDestination matching = + new FixedHttpDestination( + new HttpDestinationId("partner-catalog"), URI.create("https://api.example.test"), true); + assertThatThrownBy( + () -> HttpTargetBuilder.resolve(matching, operation(), Map.of("itemId", "../admin"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + HttpTargetBuilder.resolve( + matching, operation(), Map.of("itemId", "%2e%2e%2fadmin"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsUnsafeBaseUris() { + HttpDestinationId id = new HttpDestinationId("partner-catalog"); + + assertThatThrownBy( + () -> + new FixedHttpDestination( + id, URI.create("https://user:secret@api.example.test"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("user-info"); + assertThatThrownBy( + () -> + new FixedHttpDestination( + id, URI.create("https://api.example.test?debug=true"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("query"); + assertThatThrownBy( + () -> new FixedHttpDestination(id, URI.create("http://api.example.test"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("https"); + } + + private static HttpOperationDescriptor operation() { + return new HttpOperationDescriptor( + new HttpOperationId("catalog.get-item.v1"), + new HttpDestinationId("partner-catalog"), + 1, + HttpOperationDescriptor.Method.GET, + "/v1/items/{itemId}", + HttpOperationDescriptor.OperationSemantics.SAFE_READ, + HttpOperationDescriptor.RequestMode.NONE, + HttpOperationDescriptor.ResponseMode.BUFFERED, + Set.of(200), + 1, + 2, + 1_048_576); + } +} diff --git a/src/adapter/outbound/identifier/CLAUDE.md b/src/adapter/outbound/identifier/CLAUDE.md index a8cf5b1..c30c02f 100644 --- a/src/adapter/outbound/identifier/CLAUDE.md +++ b/src/adapter/outbound/identifier/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-outbound-identifier` - Gradle path: `:adapter:outbound:identifier` -- Focused test: `./gradlew :adapter:outbound:identifier:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:identifier:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.identifier`. diff --git a/src/adapter/outbound/identifier/build.gradle b/src/adapter/outbound/identifier/build.gradle index 8067ed7..eed16a2 100644 --- a/src/adapter/outbound/identifier/build.gradle +++ b/src/adapter/outbound/identifier/build.gradle @@ -4,9 +4,7 @@ plugins { } dependencies { - implementation project(':domain-core') implementation project(':application-core') - implementation 'com.github.f4b6a3:uuid-creator:6.1.1' testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' } diff --git a/src/adapter/outbound/identifier/gradle.lockfile b/src/adapter/outbound/identifier/gradle.lockfile index 38ffa6a..14c9a40 100644 --- a/src/adapter/outbound/identifier/gradle.lockfile +++ b/src/adapter/outbound/identifier/gradle.lockfile @@ -2,11 +2,10 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath @@ -38,15 +37,15 @@ com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspat commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -64,9 +63,9 @@ org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -85,7 +84,7 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath @@ -112,12 +111,12 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -126,32 +125,32 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath -empty= +empty=compileClasspath,runtimeClasspath diff --git a/src/adapter/outbound/messaging/CLAUDE.md b/src/adapter/outbound/messaging/CLAUDE.md index 50ad9bc..b91a708 100644 --- a/src/adapter/outbound/messaging/CLAUDE.md +++ b/src/adapter/outbound/messaging/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-outbound-messaging` - Gradle path: `:adapter:outbound:messaging` -- Focused test: `./gradlew :adapter:outbound:messaging:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:messaging:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.messaging`. @@ -15,13 +15,19 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`. - Implement outbound message publication and broker integration behind application/domain ports. - Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox publication adaptation. +- Own structured rendering of `OutboxRelayFailureReport` through the single unconditional + `Slf4jOutboxRelayFailureReportAdapter` bean. - Reuse `adapter:outbound:support` for shared technical concerns. ## Boundaries -- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- Allowed dependency edges come only from the module's + `src/config/architecture/modules.json` entry. - No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency. - Do not hide use-case sequencing or business routing policy in broker adapters. +- `OutboxMessagePublishAdapter` is mapping/send-only and emits no dependency log. The confirmed + FAILED/DEAD transition owns the one canonical ERROR; only the general fail-open publisher keeps + `FailOpenDependencyLogger`. ## Tests diff --git a/src/adapter/outbound/messaging/README.md b/src/adapter/outbound/messaging/README.md index 2c91ae8..82a45ea 100644 --- a/src/adapter/outbound/messaging/README.md +++ b/src/adapter/outbound/messaging/README.md @@ -2,12 +2,12 @@ 메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유 -correlation / fail-open 의존성 로깅을 재사용한다. +correlation / fail-open 의존성 로깅을 일반 publisher에서 재사용한다. outbox relay 실패는 이 +모듈이 별도의 typed report adapter로 구조화한다. -허용/금지 의존 정책은 `src/build.gradle` 의 -`allowedProjectDependencies['adapter:outbound:messaging']` 항목이 SSOT 다(이 모듈은 아직 별도 -CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 -기록이다. +허용/금지 의존 정책은 `src/config/architecture/modules.json`과 이 모듈의 +[CLAUDE.md](CLAUDE.md)가 소유한다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 +참조용 기록이다. ## 모듈 개요 @@ -41,4 +41,20 @@ application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 `MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께 로깅하고 삼켜(→ `:adapter:outbound:support` 의 `FailOpenDependencyLogger`) outbox/retry 로 위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort` 를 -쓴다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7). +쓴다. `OutboxMessagePublishAdapter`는 envelope mapping + broker send만 수행하며 runtime 예외를 +그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다. +반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7). + +## OutboxRelayFailureReport 구조화 ERROR + +`MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의 +`Slf4jOutboxRelayFailureReportAdapter`를 등록한다. broker 설정이 blank면 안전한 +`dependency_name=disabled`를 쓴다. 이 adapter는 confirmed FAILED/DEAD report 하나를 SLF4J 2 fluent +ERROR 하나로 렌더링한다. + +공통 field는 `error.code`, `error.category`, `dependency_name`, +`dependency_type=messaging`, `outcome`, `event_id`, `event_type`, `aggregate_id`, +`correlation_id`, `attempt_count`, `runbook_link`다. retry report만 `next_attempt_at`을 추가한다. +payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로 +붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted +FAILED/DEAD outcome을 바꾸지 않는다. diff --git a/src/adapter/outbound/messaging/build.gradle b/src/adapter/outbound/messaging/build.gradle index bf8b1f1..56bf882 100644 --- a/src/adapter/outbound/messaging/build.gradle +++ b/src/adapter/outbound/messaging/build.gradle @@ -1,14 +1,10 @@ -plugins { id 'groovy' } dependencies { - implementation project(':domain-core') implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.slf4j:slf4j-api' - - testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } -tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/messaging/gradle.lockfile b/src/adapter/outbound/messaging/gradle.lockfile index 85eb4c9..5370d98 100644 --- a/src/adapter/outbound/messaging/gradle.lockfile +++ b/src/adapter/outbound/messaging/gradle.lockfile @@ -2,8 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor @@ -41,11 +41,10 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -59,13 +58,11 @@ org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath -org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -90,7 +87,7 @@ org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs @@ -111,12 +108,11 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -125,13 +121,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -149,7 +145,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java index 1531034..905739a 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java @@ -6,8 +6,10 @@ import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher; import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher; import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher; import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter; +import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter; import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -37,13 +39,18 @@ public class MessagingConfig { @Bean public OutboxMessagePublishPort outboxMessagePublishPort( - ObjectProvider brokerProvider, - MessagingSettings settings, - FailOpenDependencyLogger dependencyLogger) { + ObjectProvider brokerProvider, MessagingSettings settings) { MessageBroker active = resolveBroker(brokerProvider, settings); return (active == null) ? new DisabledOutboxMessagePublisher() - : new OutboxMessagePublishAdapter(active, dependencyLogger); + : new OutboxMessagePublishAdapter(active); + } + + /** Always available, including when broker publication is disabled. */ + @Bean + public OutboxRelayFailureReportPort outboxRelayFailureReportPort(MessagingSettings settings) { + String dependencyName = settings.broker().isBlank() ? "disabled" : settings.broker(); + return new Slf4jOutboxRelayFailureReportAdapter(dependencyName); } private static MessageBroker resolveBroker( diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java index c79144f..19ee6b5 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java @@ -2,30 +2,26 @@ package dev.caskeleton.adapter.outbound.messaging.outbox; import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; -import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; import dev.caskeleton.application.outbox.OutboxEvent; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import java.util.Objects; /** * Outbox {@link OutboxMessagePublishPort} binding (fail-closed). Maps the claimed {@link - * OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}; on - * failure it logs and re-throws so the relay can drive the FAILED/DEAD transition (the documented - * fail-closed contract — contrast the fail-open general {@code OutboundMessagePublisher}). + * OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}. + * Runtime failures propagate unchanged and checked failures are wrapped with their cause so the + * relay can drive the FAILED/DEAD transition. This adapter emits no dependency log; the confirmed + * transition has one canonical ERROR reporter. * *

Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never * touches this class. */ public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort { - private static final String DEPENDENCY_TYPE = "messaging"; - private final MessageBroker broker; - private final FailOpenDependencyLogger dependencyLogger; - public OutboxMessagePublishAdapter( - MessageBroker broker, FailOpenDependencyLogger dependencyLogger) { - this.broker = broker; - this.dependencyLogger = dependencyLogger; + public OutboxMessagePublishAdapter(MessageBroker broker) { + this.broker = Objects.requireNonNull(broker, "broker must not be null"); } @Override @@ -34,14 +30,10 @@ public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort { OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope); try { broker.send(message); - dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish"); } catch (RuntimeException ex) { - // fail-closed: log then propagate — the relay must observe this to drive FAILED/DEAD. - dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex); throw ex; } catch (Exception ex) { // Wrap checked exceptions; preserve cause so the relay can inspect it. - dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex); throw new RuntimeException( "outbox publish failed for broker '" + broker.brokerId() + "'", ex); } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java new file mode 100644 index 0000000..e496025 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import dev.caskeleton.application.outbox.OutboxRelayFailureReport; +import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; +import dev.caskeleton.shared.error.OperationalError; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; + +/** Renders confirmed outbox relay failure transitions as one safe structured SLF4J ERROR. */ +public final class Slf4jOutboxRelayFailureReportAdapter implements OutboxRelayFailureReportPort { + + private static final String DEPENDENCY_TYPE = "messaging"; + private static final String LOG_MESSAGE = "confirmed outbox relay failure"; + + private final String dependencyName; + private final Logger logger; + + public Slf4jOutboxRelayFailureReportAdapter(String dependencyName) { + this(dependencyName, LoggerFactory.getLogger(Slf4jOutboxRelayFailureReportAdapter.class)); + } + + Slf4jOutboxRelayFailureReportAdapter(String dependencyName, Logger logger) { + if (dependencyName == null || dependencyName.isBlank()) { + throw new IllegalArgumentException("dependencyName must not be blank"); + } + this.dependencyName = dependencyName; + this.logger = Objects.requireNonNull(logger, "logger must not be null"); + } + + @Override + public void report(OutboxRelayFailureReport report) { + try { + Objects.requireNonNull(report, "report must not be null"); + boolean retryable = report.code() == OperationalError.OUTBOX_PUBLISH_FAILED; + LoggingEventBuilder event = + logger + .atError() + .setCause(report.cause()) + .addKeyValue("error.code", report.code().code()) + .addKeyValue("error.category", report.code().category().name()) + .addKeyValue("dependency_name", dependencyName) + .addKeyValue("dependency_type", DEPENDENCY_TYPE) + .addKeyValue("outcome", retryable ? "FAILED" : "DEAD") + .addKeyValue("event_id", report.eventId()) + .addKeyValue("event_type", report.eventType()) + .addKeyValue("aggregate_id", report.aggregateId()) + .addKeyValue("correlation_id", report.correlationId()) + .addKeyValue("attempt_count", report.attemptCount()) + .addKeyValue( + "runbook_link", + retryable ? "runbook://outbox/publish-failed" : "runbook://outbox/dead-letter"); + if (retryable) { + event = event.addKeyValue("next_attempt_at", report.nextAttemptAt().toString()); + } + event.log(LOG_MESSAGE); + } catch (RuntimeException ignored) { + // Diagnostics are non-authoritative and must never escape into the relay. + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java index fb6f831..3281fed 100644 --- a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java @@ -9,19 +9,16 @@ import ch.qos.logback.core.read.ListAppender; import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; -import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; import dev.caskeleton.application.outbox.OutboxEvent; import dev.caskeleton.application.outbox.OutboxEventStatus; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; -import org.slf4j.MDC; /** * Broker-agnostic outbox publish adapter (fail-closed). Covers: @@ -29,33 +26,13 @@ import org.slf4j.MDC; *

    *
  • Success — correct OutboundMessage (topic=eventType, key=aggregateId, payload=envelope JSON) * sent to the active broker. - *
  • Fail-closed — broker failure is logged then propagated (never swallowed); checked + *
  • Fail-closed — broker failure is propagated without duplicate dependency logs; checked * exceptions are wrapped. *
  • Envelope JSON fields and escaping. *
*/ class OutboxMessagePublishAdapterTest { - private ch.qos.logback.classic.Logger logbackLogger; - private ListAppender appender; - private FailOpenDependencyLogger dependencyLogger; - - @BeforeEach - void setUp() { - logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox"); - appender = new ListAppender<>(); - appender.start(); - logbackLogger.addAppender(appender); - logbackLogger.setLevel(Level.DEBUG); - dependencyLogger = new FailOpenDependencyLogger(logbackLogger); - } - - @AfterEach - void tearDown() { - logbackLogger.detachAppender(appender); - MDC.clear(); - } - /** Fake broker (brokerId "kafka") capturing sends, optionally failing with a given throwable. */ private static final class FakeBroker implements MessageBroker { final List sent = new ArrayList<>(); @@ -102,8 +79,7 @@ class OutboxMessagePublishAdapterTest { @Test void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() { FakeBroker broker = new FakeBroker(); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); + OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); adapter.publish(sampleEvent()); @@ -116,8 +92,7 @@ class OutboxMessagePublishAdapterTest { @Test void publishEnvelopeContainsAllFields() { FakeBroker broker = new FakeBroker(); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); + OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); adapter.publish(sampleEvent()); @@ -134,8 +109,7 @@ class OutboxMessagePublishAdapterTest { @Test void publishEnvelopeFieldValuesMatchEvent() { FakeBroker broker = new FakeBroker(); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); + OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); adapter.publish(sampleEvent()); @@ -156,68 +130,53 @@ class OutboxMessagePublishAdapterTest { @Test void publishFailurePropagatesAsRuntimeException() { FakeBroker broker = new FakeBroker(new IllegalStateException("broker down")); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); + OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); - assertThatThrownBy(() -> adapter.publish(sampleEvent())).isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> adapter.publish(sampleEvent())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("broker down"); } @Test - void publishFailureIsLoggedBeforePropagation() { - MDC.put(OutboundCorrelation.MDC_KEY, "corr-fail-1"); + void publishFailureEmitsNoDuplicateDependencyLog() { + ch.qos.logback.classic.Logger dependencyLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(FailOpenDependencyLogger.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + dependencyLogger.addAppender(appender); + dependencyLogger.setLevel(Level.DEBUG); FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable")); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); + OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); try { adapter.publish(sampleEvent()); } catch (RuntimeException ignored) { // expected + } finally { + dependencyLogger.detachAppender(appender); + appender.stop(); } - boolean warnLogged = - appender.list.stream() - .anyMatch( - e -> - e.getLevel() == Level.WARN - && e.getFormattedMessage().contains("corr-fail-1")); - assertThat(warnLogged).as("Expected a WARN log with the correlationId on failure").isTrue(); - } - - @Test - void publishFailureLogCarriesDependencyAndOperation() { - FakeBroker broker = new FakeBroker(new RuntimeException("connection refused")); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); - - try { - adapter.publish(sampleEvent()); - } catch (RuntimeException ignored) { - // expected - } - - String msg = - appender.list.stream() - .filter(e -> e.getLevel() == Level.WARN) - .findFirst() - .map(ILoggingEvent::getFormattedMessage) - .orElse(""); - assertThat(msg) - .contains("dependency_name=\"kafka\"") - .contains("dependency_type=\"messaging\"") - .contains("operation=\"publish\""); + assertThat(appender.list).isEmpty(); } @Test void publishWrapsCheckedExceptionInRuntimeException() { FakeBroker broker = new FakeBroker(new IOException("network error")); - OutboxMessagePublishAdapter adapter = - new OutboxMessagePublishAdapter(broker, dependencyLogger); + OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); assertThatThrownBy(() -> adapter.publish(sampleEvent())) .isInstanceOf(RuntimeException.class) .hasCauseInstanceOf(IOException.class); } + + @Test + void adapterStateContainsOnlyTheBroker() { + assertThat( + Arrays.stream(OutboxMessagePublishAdapter.class.getDeclaredFields()) + .map(field -> field.getName())) + .containsExactly("broker"); + } } @Nested @@ -237,7 +196,7 @@ class OutboxMessagePublishAdapterTest { OutboxEventStatus.IN_FLIGHT, 1); FakeBroker broker = new FakeBroker(); - new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(eventWithQuote); + new OutboxMessagePublishAdapter(broker).publish(eventWithQuote); assertThat(broker.sent.get(0).payload()).contains("Has\\\"Quote"); } @@ -256,7 +215,7 @@ class OutboxMessagePublishAdapterTest { OutboxEventStatus.IN_FLIGHT, 1); FakeBroker broker = new FakeBroker(); - new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(event); + new OutboxMessagePublishAdapter(broker).publish(event); String payload = broker.sent.get(0).payload(); assertThat(payload).contains("{\"nested\":{\"a\":1}}"); diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java new file mode 100644 index 0000000..0dd2b94 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.application.outbox.OutboxRelayFailureReport; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.KeyValuePair; + +class Slf4jOutboxRelayFailureReportAdapterTest { + + private ch.qos.logback.classic.Logger logger; + private ListAppender appender; + private Slf4jOutboxRelayFailureReportAdapter adapter; + + @BeforeEach + void setUp() { + logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox.relay.failure.report"); + logger.setAdditive(false); + logger.setLevel(Level.ERROR); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + adapter = new Slf4jOutboxRelayFailureReportAdapter("kafka", logger); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + appender.stop(); + } + + @Test + void retryableFailureEmitsOneSafeStructuredErrorWithCause() { + RuntimeException cause = new RuntimeException("unsafe-exception-derived-value"); + Instant nextAttemptAt = Instant.parse("2026-07-25T01:02:03Z"); + + adapter.report( + OutboxRelayFailureReport.retryableFailure( + "evt-1", "WorkLogReserved", "agg-1", "corr-1", 2, nextAttemptAt, cause)); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.getFirst(); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).isEqualTo("confirmed outbox relay failure"); + assertThat(keyValues(event)) + .containsExactlyInAnyOrderEntriesOf( + Map.ofEntries( + Map.entry("error.code", "OUTBOX_PUBLISH_FAILED"), + Map.entry("error.category", "TRANSIENT_DEPENDENCY"), + Map.entry("dependency_name", "kafka"), + Map.entry("dependency_type", "messaging"), + Map.entry("outcome", "FAILED"), + Map.entry("event_id", "evt-1"), + Map.entry("event_type", "WorkLogReserved"), + Map.entry("aggregate_id", "agg-1"), + Map.entry("correlation_id", "corr-1"), + Map.entry("attempt_count", 2), + Map.entry("runbook_link", "runbook://outbox/publish-failed"), + Map.entry("next_attempt_at", "2026-07-25T01:02:03Z"))); + assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause); + assertThat(event.getFormattedMessage()).doesNotContain("unsafe-exception-derived-value"); + assertThat(keyValues(event).toString()) + .doesNotContain("payload-secret", "idempotency-secret", "unsafe-exception-derived-value"); + } + + @Test + void deadLetterEmitsTerminalMappingWithoutNextAttemptAt() { + RuntimeException cause = new RuntimeException("broker down"); + + adapter.report( + OutboxRelayFailureReport.deadLetter( + "evt-2", "WorkLogReserved", "agg-2", "corr-2", 3, cause)); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.getFirst(); + assertThat(keyValues(event)) + .containsEntry("error.code", "OUTBOX_DEAD_LETTER") + .containsEntry("error.category", "INTERNAL") + .containsEntry("outcome", "DEAD") + .containsEntry("runbook_link", "runbook://outbox/dead-letter") + .doesNotContainKey("next_attempt_at"); + assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause); + } + + @Test + void loggerRuntimeExceptionIsContained() { + Logger throwingLogger = mock(Logger.class); + when(throwingLogger.atError()).thenThrow(new RuntimeException("logger failed")); + Slf4jOutboxRelayFailureReportAdapter throwingAdapter = + new Slf4jOutboxRelayFailureReportAdapter("kafka", throwingLogger); + + assertThatCode( + () -> + throwingAdapter.report( + OutboxRelayFailureReport.deadLetter( + "evt-3", + "WorkLogReserved", + "agg-3", + "corr-3", + 3, + new RuntimeException("broker down")))) + .doesNotThrowAnyException(); + } + + @Test + void nullReportIsContainedByTheNoThrowAdapterContract() { + assertThatCode(() -> adapter.report(null)).doesNotThrowAnyException(); + } + + private static Map keyValues(ILoggingEvent event) { + Map values = new LinkedHashMap<>(); + for (KeyValuePair pair : event.getKeyValuePairs()) { + values.put(pair.key, pair.value); + } + return values; + } +} diff --git a/src/adapter/outbound/notification/CLAUDE.md b/src/adapter/outbound/notification/CLAUDE.md index ab0eb66..a8d9f18 100644 --- a/src/adapter/outbound/notification/CLAUDE.md +++ b/src/adapter/outbound/notification/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-outbound-notification` - Gradle path: `:adapter:outbound:notification` -- Focused test: `./gradlew :adapter:outbound:notification:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:notification:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.notification`. @@ -18,7 +18,8 @@ Package root: `dev.caskeleton.adapter.outbound.notification`. ## Boundaries -- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- Allowed dependency edges come only from the module's + `src/config/architecture/modules.json` entry. - No inbound DTO/controller, persistence, bootstrap, or sample dependency. - Provider selection may route configured channels but must not encode business eligibility rules. diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle index d332c1a..555e34e 100644 --- a/src/adapter/outbound/notification/build.gradle +++ b/src/adapter/outbound/notification/build.gradle @@ -1,6 +1,4 @@ -plugins { id 'groovy' } dependencies { - implementation project(':domain-core') implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') @@ -8,8 +6,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.springframework:spring-web' // Slack webhook client (RestClient) implementation 'org.slf4j:slf4j-api' - - testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } -tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/notification/gradle.lockfile b/src/adapter/outbound/notification/gradle.lockfile index edf2de5..32d65da 100644 --- a/src/adapter/outbound/notification/gradle.lockfile +++ b/src/adapter/outbound/notification/gradle.lockfile @@ -2,8 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor @@ -41,11 +41,10 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -59,13 +58,11 @@ org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath -org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -90,7 +87,7 @@ org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs @@ -111,12 +108,11 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -125,13 +121,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -149,7 +145,7 @@ org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompi org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/objectstorage/CLAUDE.md b/src/adapter/outbound/objectstorage/CLAUDE.md index e8d7ff9..e665427 100644 --- a/src/adapter/outbound/objectstorage/CLAUDE.md +++ b/src/adapter/outbound/objectstorage/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-outbound-objectstorage` - Gradle path: `:adapter:outbound:objectstorage` -- Focused test: `./gradlew :adapter:outbound:objectstorage:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:objectstorage:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.objectstorage`. Driven (outbound) adapter implementing `dev.caskeleton.application.storage.ObjectStoragePort` (application-core). Design @@ -22,7 +22,7 @@ rationale lives in [README.md](README.md). ## Allowed - Project deps: `:application-core`, `:shared-contract` — SSOT is the - `adapter-outbound-objectstorage` entry in `.harness/project/modules.yaml`; `src/build.gradle` + `adapter-outbound-objectstorage` entry in `src/config/architecture/modules.json`; `src/build.gradle` enforces it. No `:domain-core`, no sibling adapters (shared outbound code would go through `:adapter:outbound:support` if ever needed). diff --git a/src/adapter/outbound/objectstorage/build.gradle b/src/adapter/outbound/objectstorage/build.gradle index fde99d4..0c25eb6 100644 --- a/src/adapter/outbound/objectstorage/build.gradle +++ b/src/adapter/outbound/objectstorage/build.gradle @@ -20,7 +20,8 @@ dependencies { implementation project(':application-core') implementation project(':shared-contract') - implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.slf4j:slf4j-api' implementation 'software.amazon.awssdk:s3' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' diff --git a/src/adapter/outbound/objectstorage/gradle.lockfile b/src/adapter/outbound/objectstorage/gradle.lockfile index 70aa143..6543162 100644 --- a/src/adapter/outbound/objectstorage/gradle.lockfile +++ b/src/adapter/outbound/objectstorage/gradle.lockfile @@ -1,9 +1,9 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath @@ -11,16 +11,16 @@ com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath, com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -63,7 +63,7 @@ io.netty:netty-transport-classes-epoll:4.2.7.Final=runtimeClasspath,testRuntimeC io.netty:netty-transport-native-unix-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath io.netty:netty-transport:4.2.7.Final=runtimeClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -81,9 +81,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle,runtimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpcore:4.4.16=checkstyle,runtimeClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -117,10 +117,10 @@ org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs @@ -132,7 +132,7 @@ org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,tes org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -145,13 +145,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -171,7 +171,7 @@ org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testR org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath software.amazon.awssdk:annotations:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath software.amazon.awssdk:apache-client:2.30.0=runtimeClasspath,testRuntimeClasspath software.amazon.awssdk:arns:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index 26d6e29..705d6da 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-outbound-persistence-jpa` - Gradle path: `:adapter:outbound:persistence-jpa` -- Focused test: `./gradlew :adapter:outbound:persistence-jpa:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:persistence-jpa:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.persistence`. diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 56bbf45..e4bf00b 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -4,7 +4,6 @@ // SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor // Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral). dependencies { - implementation project(':domain-core') implementation project(':application-core') implementation project(':shared-contract') @@ -16,9 +15,9 @@ dependencies { // Vendor (PostgreSQL): Flyway migration API + PostgreSQL driver/dialect. Used only by the // .postgresql subpackage; the RDBMS base stays vendor-neutral (PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL). implementation 'org.springframework.boot:spring-boot-starter-flyway' - implementation 'org.flywaydb:flyway-core' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.flywaydb:flyway-database-postgresql' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index d361d28..14cf6ab 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -135,6 +135,7 @@ org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClass org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/persistence-mongo/CLAUDE.md b/src/adapter/outbound/persistence-mongo/CLAUDE.md index 84ed117..c168845 100644 --- a/src/adapter/outbound/persistence-mongo/CLAUDE.md +++ b/src/adapter/outbound/persistence-mongo/CLAUDE.md @@ -4,47 +4,45 @@ - Module ID: `adapter-outbound-persistence-mongo` - Gradle path: `:adapter:outbound:persistence-mongo` -- Focused test: `./gradlew :adapter:outbound:persistence-mongo:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:persistence-mongo:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. -Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — **lightweight -Spring Data MongoDB scaffolding**. Design rationale lives in [README.md](README.md). +Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — opt-in Spring +Data MongoDB infrastructure. Design rationale lives in [README.md](README.md). ## Responsibility -- Demonstrate MongoDB-backed persistence: opt-in Mongo config + a self-contained example document / - repository / adapter showing the document↔domain mapping boundary. It does **not** reimplement - idempotency / outbox / lock on Mongo (those stay JPA-only). +- Provide opt-in Mongo client and template infrastructure without shipping a fake business domain. +- Real forks add their own document, repository, mapper, and application/domain port implementation. +- It does **not** reimplement idempotency / outbox / lock on Mongo (those stay JPA-only). - Opt-in: `MongoPersistenceConfig` re-imports the Mongo auto-configuration (`@ImportAutoConfiguration`) - and enables repositories (`@EnableMongoRepositories`, scoped to this package via - `basePackageClasses`) only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The - connection URI comes from Spring's standard `spring.data.mongodb.uri`. + only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The connection URI and + database come from Spring's standard `spring.data.mongodb.*` settings. +- `MongoOptInAutoConfigurationImportFilter`, registered through `META-INF/spring.factories`, blocks + Boot 4's classpath-driven sync/reactive/data/repository/health/metrics Mongo auto-configuration + when the module enable flag is absent or false. ## Allowed -- Project deps: `:application-core`, `:shared-contract` — SSOT is the - `adapter-outbound-persistence-mongo` entry in `.harness/project/modules.yaml`; `src/build.gradle` - enforces it. No - `:domain-core`, no sibling adapters. +- No project dependency is required by the generic infrastructure. The allowed-edge SSOT remains + the `adapter-outbound-persistence-mongo` entry in `src/config/architecture/modules.json`. - External: `org.springframework.boot:spring-boot-starter-data-mongodb` (version via the shared - Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor). Test-only: - Testcontainers (`testcontainers`, `testcontainers-junit-jupiter`), BOM-managed. + Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor). ## Forbidden - Inbound adapters, sibling outbound adapters, `app-bootstrap`, `sample-portfolio` (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). -- Leaking the `ExampleMongoDocument` type outside the adapter — the adapter maps documents to the - module-local `ExampleRecord` at the edge. -- Inventing an `application-core` port for the example (scaffolding stays self-contained); adding - idempotency/outbox/lock on Mongo. +- Shipping placeholder `Example*` document, repository, record, or adapter types in production. +- Adding idempotency/outbox/lock on Mongo without a separately approved contract. - Fully-qualified inline type references; more than one public top-level type per file. ## Tests -`ExampleMongoMapperTest` (pure mapping, no container), `ExampleMongoRepositoryIT` (Testcontainers -MongoDB save/find/derived-query, `disabledWithoutDocker`). +`MongoPersistenceConfigTest` proves default/false behavior through an actual +`@EnableAutoConfiguration` context, typed enablement binding, and enabled infrastructure with a +mock `MongoClient` plus a real `MongoTemplate` without a network connection. ```bash cd src diff --git a/src/adapter/outbound/persistence-mongo/README.md b/src/adapter/outbound/persistence-mongo/README.md index 203e038..cf66263 100644 --- a/src/adapter/outbound/persistence-mongo/README.md +++ b/src/adapter/outbound/persistence-mongo/README.md @@ -1,68 +1,50 @@ -# adapter:outbound:persistence-mongo — design-decision reference +# adapter:outbound:persistence-mongo -MongoDB persistence outbound (driven) adapter — **lightweight scaffolding**. Package root: -`dev.caskeleton.adapter.outbound.mongo`. Wires Spring Data MongoDB behind an opt-in -`@ConditionalOnProperty` selector and ships a demonstrative document / repository / adapter that -shows the document↔domain mapping boundary a fork follows. Mirrors the existing outbound adapters -(notification / cache-redis / httpclient / objectstorage / fileserver). +`dev.caskeleton.adapter.outbound.mongo` 패키지의 opt-in Spring Data MongoDB 인프라 모듈이다. +템플릿 production 코드에 가짜 비즈니스 `Example*` 타입을 두지 않고, 실제 프로젝트가 자신의 +document/repository/mapper와 application 또는 domain port 구현을 추가할 수 있는 구성 경계만 +제공한다. -The allowed/forbidden dependency policy is owned by `src/build.gradle`'s -`allowedProjectDependencies['adapter:outbound:persistence-mongo']` (SSOT). Module rules live in -[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code -comments. +## 활성화 -## Scope — deliberately lightweight +기본값은 비활성이다. -This module is **scaffolding, not a full persistence implementation**. It demonstrates *how* a fork -adds MongoDB-backed storage; it does **not** reimplement idempotency, outbox, or distributed lock on -Mongo (those stay JPA-only in `adapter:outbound:persistence-jpa`). There is no `application-core` -port here on purpose — the demonstrative example is entirely self-contained inside the adapter -package so the skeleton stays decoupled and copy-paste-forkable. +```properties +ca-skeleton.persistence-mongo.enabled=true +spring.data.mongodb.uri=mongodb://localhost:27017/portfolio +``` -## Module overview +활성화 시 `MongoPersistenceConfig`가 Spring Boot의 Mongo client 및 data auto-configuration을 +명시적으로 가져와 `MongoClient`와 `MongoTemplate`을 구성한다. repository scanning은 템플릿이 +임의로 소유하지 않는다. 실제 consumer가 자신의 repository package와 composition을 명시해야 +한다. -An **opt-in** MongoDB module placed behind Spring Data MongoDB: +Mongo starter는 classpath만으로도 Boot auto-configuration 후보를 등록하므로 config의 조건만으로는 +기본 비활성을 보장할 수 없다. `MongoOptInAutoConfigurationImportFilter`가 Boot 4의 sync/reactive +client, data, repository, health, metrics Mongo auto-configuration을 default/false에서 후보군에서 +제외한다. 필터는 Boot 4가 `AutoConfigurationImportFilter`를 찾는 `META-INF/spring.factories`에 +등록되어 있으며, `enabled=true`일 때는 후보를 그대로 허용한다. -- `MongoPersistenceConfig` re-imports the Mongo auto-configuration with `@ImportAutoConfiguration` - (`MongoAutoConfiguration`, `DataMongoAutoConfiguration`, `DataMongoRepositoriesAutoConfiguration`) - and enables the repositories with `@EnableMongoRepositories(basePackageClasses = …)` scoped to this - package — but only when `ca-skeleton.persistence-mongo.enabled=true`. `@ImportAutoConfiguration` is - an explicit import unaffected by `spring.autoconfigure.exclude`, so the driver never connects when - the module is merely on the classpath. This mirrors ha-tmpl's `MongoPersistenceConfig`. -- The connection URI is read from Spring's standard `spring.data.mongodb.uri` (owned by Spring - Boot's `MongoProperties`). The module's own `MongoPersistenceProperties` - (`ca-skeleton.persistence-mongo.*`) owns only the `enabled` opt-in switch and a demonstrative - `database` name. +`MongoPersistenceProperties`는 모듈 opt-in만 소유한다. URI, database, credential은 Spring의 +표준 `spring.data.mongodb.*` 설정을 사용한다. -Selector: `ca-skeleton.persistence-mongo.enabled=true` (default `false`). +## 의존성 경계 -## The demonstrative example (document↔domain boundary) +- production project dependency 없음 +- Spring Boot MongoDB starter와 configuration processor만 사용 +- JPA persistence adapter 및 다른 adapter와 의존 관계 없음 +- idempotency, outbox, distributed lock은 기존 JPA adapter 책임을 유지 -- `ExampleRecord` — a small, self-contained "domain" value (NOT a real domain type, NOT an - `application-core` type). -- `ExampleMongoDocument` — the `@Document` persistence shape (`@Id`, `@Field` BSON names), kept - separate from the domain value exactly like a JPA entity is kept separate from its aggregate. -- `ExampleMongoRepository extends MongoRepository` — CRUD plus a - derived-query method (`findByNameIgnoreCase`) demonstrating Spring Data query derivation. -- `ExampleMongoMapper` — a pure, package-private static translator (`toDomain` / `toDocument`), - trivially unit-testable without a running MongoDB. -- `ExampleMongoRepositoryAdapter` — maps at the edge so the document type never leaks to callers; - the exact pattern a fork follows for a real aggregate/port. +## 검증 -**How a fork replaces this:** swap `ExampleMongoDocument`/`ExampleMongoRepository` for a real -document + repository (renaming the collection and fields), map to the fork's real aggregate in -`ExampleMongoMapper`, and — if the fork wants a framework-neutral seam — implement an -`application-core` port from the adapter. `MongoPersistenceConfig` keeps working unchanged. +`MongoPersistenceConfigTest`는 다음을 검증한다. -## Tests - -- `ExampleMongoMapperTest` — pure document↔domain mapping round-trip; no MongoDB needed. -- `ExampleMongoRepositoryIT` — real save/findById/derived-query round-trip against Testcontainers - MongoDB (a core `GenericContainer` running `mongo:7.0`); the repository proxy is built directly - with `MongoRepositoryFactory` so no full Spring context is required. Skipped automatically when - Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`). +- 실제 `@EnableAutoConfiguration` context의 기본/false 모드에서 Mongo 인프라가 생성되지 않는다. +- enable flag가 typed properties에 바인딩된다. +- enabled 모드는 mock `MongoClient`로 네트워크 없이 실제 `MongoTemplate`을 생성한다. +- `Example` production bean이 존재하지 않는다. ```bash cd src -./gradlew :adapter:outbound:persistence-mongo:check +./gradlew :adapter:outbound:persistence-mongo:check --console=plain ``` diff --git a/src/adapter/outbound/persistence-mongo/build.gradle b/src/adapter/outbound/persistence-mongo/build.gradle index b18168c..81e0767 100644 --- a/src/adapter/outbound/persistence-mongo/build.gradle +++ b/src/adapter/outbound/persistence-mongo/build.gradle @@ -1,27 +1,13 @@ -// Driven adapter: NoSQL persistence scaffolding via Spring Data MongoDB. This is a LIGHTWEIGHT, -// opt-in skeleton — it wires the Mongo driver + a demonstrative document/repository/adapter that -// shows the document<->domain mapping boundary a fork would follow. It does NOT reimplement -// idempotency/outbox/lock on Mongo (those stay JPA-only). Mongo auto-configuration is imported and -// the repositories enabled ONLY when ca-skeleton.persistence-mongo.enabled=true -// (MongoPersistenceConfig), so the driver never connects when the module is merely on the classpath. +// Driven adapter: opt-in Spring Data MongoDB infrastructure. This leaf owns only enablement and +// Mongo client/template auto-configuration; consuming projects add real documents, repositories, +// mappings, and ports without shipping a fake business domain in the template. // // spring-boot-starter-data-mongodb's version is managed by the Spring Boot BOM (applied to every -// module in src/build.gradle), so no module-scoped platform is needed. The Testcontainers MongoDB -// integration test uses the BOM-managed Testcontainers, and is skipped when Docker is unavailable. -description = 'Outbound adapter: NoSQL persistence scaffolding (Spring Data MongoDB)' +// module in src/build.gradle), so no module-scoped platform is needed. +description = 'Outbound adapter: opt-in Spring Data MongoDB infrastructure' dependencies { - implementation project(':application-core') - implementation project(':shared-contract') - implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - - // test-only: Testcontainers MongoDB integration test for the repository round-trip. Uses the core - // GenericContainer (no dedicated module) so the repository save/find runs against a real MongoDB - // when Docker is available and is skipped (disabledWithoutDocker) otherwise — mirroring the - // object-storage module's MinIO integration test. - testImplementation 'org.testcontainers:testcontainers' - testImplementation 'org.testcontainers:testcontainers-junit-jupiter' } diff --git a/src/adapter/outbound/persistence-mongo/gradle.lockfile b/src/adapter/outbound/persistence-mongo/gradle.lockfile index 0adb649..1122f45 100644 --- a/src/adapter/outbound/persistence-mongo/gradle.lockfile +++ b/src/adapter/outbound/persistence-mongo/gradle.lockfile @@ -6,9 +6,6 @@ ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testComp ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath @@ -38,9 +35,7 @@ com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle @@ -55,14 +50,12 @@ javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle @@ -88,7 +81,6 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath @@ -119,7 +111,6 @@ org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath @@ -166,8 +157,6 @@ org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java deleted file mode 100644 index 2f6c89a..0000000 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -/** - * MongoDB document model for the demonstrative {@link ExampleRecord}. Kept separate from the - * "domain" value exactly like a JPA entity is kept separate from its aggregate — this is the - * persistence shape (BSON field names, {@code @Id}), not the domain shape. - * - *

Modelled as a mutable JavaBean because that is the least-surprising shape for Spring Data - * MongoDB's mapping (no-arg construct + field population). A fork renames the collection and fields - * to match its real document. - */ -@Document(collection = "ca_skeleton_examples") -public class ExampleMongoDocument { - - @Id private String id; - - private String name; - - @Field("qty") - private int quantity; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public int getQuantity() { - return quantity; - } - - public void setQuantity(int quantity) { - this.quantity = quantity; - } -} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java deleted file mode 100644 index cbb9558..0000000 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -/** - * Translation between the demonstrative {@link ExampleRecord} "domain" value and its {@link - * ExampleMongoDocument} persistence shape. Package-private and static: mapping is a pure function - * with no framework dependency, so it is trivially unit-testable without a running MongoDB (see - * {@code ExampleMongoMapperTest}). - * - *

This is the boundary a fork keeps: the repository/adapter never leak the document type - * outward; callers receive only the domain value. - */ -final class ExampleMongoMapper { - - private ExampleMongoMapper() {} - - static ExampleRecord toDomain(ExampleMongoDocument document) { - return new ExampleRecord(document.getId(), document.getName(), document.getQuantity()); - } - - static ExampleMongoDocument toDocument(ExampleRecord record) { - ExampleMongoDocument document = new ExampleMongoDocument(); - document.setId(record.id()); - document.setName(record.name()); - document.setQuantity(record.quantity()); - return document; - } -} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java deleted file mode 100644 index 7596112..0000000 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -import java.util.List; -import org.springframework.data.mongodb.repository.MongoRepository; - -/** - * Spring Data MongoDB repository for {@link ExampleMongoDocument}. Extending {@link - * MongoRepository} supplies the CRUD surface (save / findById / delete / count …); the - * derived-query method below demonstrates Spring Data's query derivation. A fork replaces the - * document type parameter and the derived queries with its own. - */ -public interface ExampleMongoRepository extends MongoRepository { - - /** Derived query — case-insensitive lookup by the {@code name} field. */ - List findByNameIgnoreCase(String name); -} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java deleted file mode 100644 index 94eed90..0000000 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -import java.util.List; -import java.util.Optional; - -/** - * Demonstrative repository adapter: the boundary between the Spring Data {@link - * ExampleMongoRepository} (documents) and the module-local {@link ExampleRecord} "domain" value. - * Every method maps at the edge via {@link ExampleMongoMapper}, so the document type never leaks to - * callers — the exact pattern a fork follows for a real aggregate/port. - * - *

Deliberately a plain class (no {@code @Component}); {@link MongoPersistenceConfig} assembles - * it as a bean only when the module is opted in, mirroring the object-storage / file-server - * adapters. - */ -public class ExampleMongoRepositoryAdapter { - - private final ExampleMongoRepository repository; - - public ExampleMongoRepositoryAdapter(ExampleMongoRepository repository) { - this.repository = repository; - } - - /** Inserts or updates the document for {@code record} and returns the persisted value. */ - public ExampleRecord save(ExampleRecord record) { - ExampleMongoDocument saved = repository.save(ExampleMongoMapper.toDocument(record)); - return ExampleMongoMapper.toDomain(saved); - } - - /** Reads by id, mapping the document back to the domain value; {@code empty()} when absent. */ - public Optional findById(String id) { - return repository.findById(id).map(ExampleMongoMapper::toDomain); - } - - /** Derived-query lookup by name, mapped to domain values. */ - public List findByName(String name) { - return repository.findByNameIgnoreCase(name).stream() - .map(ExampleMongoMapper::toDomain) - .toList(); - } - - /** Idempotent delete by id. */ - public void deleteById(String id) { - repository.deleteById(id); - } - - /** Total document count in the collection. */ - public long count() { - return repository.count(); - } -} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java deleted file mode 100644 index 2010e0c..0000000 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -/** - * A small, self-contained "domain" value used purely to demonstrate the document<->domain - * mapping boundary. It is intentionally NOT a real domain type and NOT an {@code application-core} - * type — the persistence-mongo module is lightweight scaffolding, so the example lives entirely - * inside the adapter package. - * - *

A fork replaces this with its real aggregate (typically owned by {@code domain-core}) and maps - * to it in {@link ExampleMongoMapper} exactly as shown here. - */ -public record ExampleRecord(String id, String name, int quantity) {} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoOptInAutoConfigurationImportFilter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoOptInAutoConfigurationImportFilter.java new file mode 100644 index 0000000..732b079 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoOptInAutoConfigurationImportFilter.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import java.util.Set; +import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter; +import org.springframework.boot.autoconfigure.AutoConfigurationMetadata; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; + +/** + * Prevents Spring Boot's classpath-driven Mongo auto-configurations from bypassing this module's + * explicit opt-in property. + * + *

The Mongo starter contributes its auto-configurations directly through Boot's import metadata. + * Consequently, conditioning only {@link MongoPersistenceConfig} is insufficient: a normal + * {@code @EnableAutoConfiguration} application would still create a client and template. This + * filter keeps all Boot 4 sync, reactive, repository, health, and metrics Mongo imports out of the + * candidate set until {@code ca-skeleton.persistence-mongo.enabled=true}. + */ +public final class MongoOptInAutoConfigurationImportFilter + implements AutoConfigurationImportFilter, EnvironmentAware { + + private static final String ENABLE_PROPERTY = "ca-skeleton.persistence-mongo.enabled"; + + private static final Set MONGO_AUTO_CONFIGURATIONS = + Set.of( + "org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration", + "org.springframework.boot.mongodb.autoconfigure.MongoReactiveAutoConfiguration", + "org.springframework.boot.mongodb.autoconfigure.health.MongoHealthContributorAutoConfiguration", + "org.springframework.boot.mongodb.autoconfigure.health.MongoReactiveHealthContributorAutoConfiguration", + "org.springframework.boot.mongodb.autoconfigure.metrics.MongoMetricsAutoConfiguration", + "org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration", + "org.springframework.boot.data.mongodb.autoconfigure.DataMongoReactiveAutoConfiguration", + "org.springframework.boot.data.mongodb.autoconfigure.DataMongoReactiveRepositoriesAutoConfiguration", + "org.springframework.boot.data.mongodb.autoconfigure.DataMongoRepositoriesAutoConfiguration"); + + private Environment environment; + + @Override + public boolean[] match( + String[] autoConfigurationClasses, AutoConfigurationMetadata autoConfigurationMetadata) { + boolean enabled = + environment != null + && "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false")); + boolean[] matches = new boolean[autoConfigurationClasses.length]; + for (int index = 0; index < autoConfigurationClasses.length; index++) { + String autoConfigurationClass = autoConfigurationClasses[index]; + matches[index] = + enabled + || autoConfigurationClass == null + || !MONGO_AUTO_CONFIGURATIONS.contains(autoConfigurationClass); + } + return matches; + } + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java index 5f03529..ca105c0 100644 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java @@ -4,26 +4,18 @@ import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration; -import org.springframework.boot.data.mongodb.autoconfigure.DataMongoRepositoriesAutoConfiguration; import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; /** - * Opt-in wiring for the MongoDB persistence scaffolding, mirroring ha-tmpl's {@code - * MongoPersistenceConfig}. The whole configuration — and therefore the Mongo driver connection, - * repositories, and the demonstrative adapter bean — activates ONLY when {@code - * ca-skeleton.persistence-mongo.enabled=true}. + * Opt-in wiring for MongoDB infrastructure. The configuration, Mongo client, and template activate + * only when {@code ca-skeleton.persistence-mongo.enabled=true}. * *

{@link ImportAutoConfiguration} is an explicit import that is not affected by {@code * spring.autoconfigure.exclude}, so re-importing the Mongo auto-configuration here cleanly turns * MongoDB on for the opted-in profile without the driver ever connecting when the module is merely - * on the classpath. {@link EnableMongoRepositories} is scoped to this package via {@code - * basePackageClasses} so repository scanning never reaches beyond the skeleton. - * - *

A fork replaces {@link ExampleMongoRepository}/{@link ExampleMongoDocument} with its real - * document + repository and this config keeps working unchanged. + * on the classpath. A consuming project adds its document, repository, and mapping adapter in this + * leaf and explicitly owns any repository scanning it requires. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty( @@ -31,21 +23,5 @@ import org.springframework.data.mongodb.repository.config.EnableMongoRepositorie name = "enabled", havingValue = "true") @EnableConfigurationProperties(MongoPersistenceProperties.class) -@ImportAutoConfiguration({ - MongoAutoConfiguration.class, - DataMongoAutoConfiguration.class, - DataMongoRepositoriesAutoConfiguration.class -}) -@EnableMongoRepositories(basePackageClasses = ExampleMongoRepository.class) -public class MongoPersistenceConfig { - - /** - * Assembles the demonstrative adapter as a bean (the adapter is a plain class), mirroring how the - * object-storage / file-server modules assemble their adapters. A fork swaps this for its real - * repository adapter. - */ - @Bean - ExampleMongoRepositoryAdapter exampleMongoRepositoryAdapter(ExampleMongoRepository repository) { - return new ExampleMongoRepositoryAdapter(repository); - } -} +@ImportAutoConfiguration({MongoAutoConfiguration.class, DataMongoAutoConfiguration.class}) +public class MongoPersistenceConfig {} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java index 2b4804f..c65ebc4 100644 --- a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java @@ -3,14 +3,13 @@ package dev.caskeleton.adapter.outbound.mongo; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Typed settings for the MongoDB persistence scaffolding, bound from {@code - * ca-skeleton.persistence-mongo.*}. Bound as a mutable JavaBean (not a record) so a fork can leave - * any subset of fields unset and inherit the defaults below. + * Typed enablement settings for the MongoDB infrastructure, bound from {@code + * ca-skeleton.persistence-mongo.*}. * *

The Mongo connection URI is intentionally NOT modelled here — it is read from Spring's * own standard {@code spring.data.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}), - * which keeps credentials/host wiring in the one place operators already expect. This class only - * owns the module's own opt-in switch and a demonstrative logical-database name. + * which keeps credentials, host, and database wiring in the one place operators already expect. + * This class owns only the module's opt-in switch. */ @ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo") public class MongoPersistenceProperties { @@ -22,13 +21,6 @@ public class MongoPersistenceProperties { */ private boolean enabled = false; - /** - * Demonstrative logical database name. This is scaffolding metadata a fork may surface in - * diagnostics; the effective database is whatever {@code spring.data.mongodb.uri} (or {@code - * spring.data.mongodb.database}) resolves to. - */ - private String database = "ca_skeleton"; - public boolean isEnabled() { return enabled; } @@ -36,12 +28,4 @@ public class MongoPersistenceProperties { public void setEnabled(boolean enabled) { this.enabled = enabled; } - - public String getDatabase() { - return database; - } - - public void setDatabase(String database) { - this.database = database; - } } diff --git a/src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring.factories b/src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..f841a58 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\ +dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java deleted file mode 100644 index 512c0b4..0000000 --- a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -/** - * Pure document<->domain mapping contract for {@link ExampleMongoMapper} — no MongoDB needed. - */ -class ExampleMongoMapperTest { - - @Test - void toDocumentCopiesEveryField() { - ExampleRecord record = new ExampleRecord("id-1", "widget", 7); - - ExampleMongoDocument document = ExampleMongoMapper.toDocument(record); - - assertThat(document.getId()).isEqualTo("id-1"); - assertThat(document.getName()).isEqualTo("widget"); - assertThat(document.getQuantity()).isEqualTo(7); - } - - @Test - void toDomainCopiesEveryField() { - ExampleMongoDocument document = new ExampleMongoDocument(); - document.setId("id-2"); - document.setName("gadget"); - document.setQuantity(3); - - ExampleRecord record = ExampleMongoMapper.toDomain(document); - - assertThat(record).isEqualTo(new ExampleRecord("id-2", "gadget", 3)); - } - - @Test - void roundTripThroughDocumentPreservesTheDomainValue() { - ExampleRecord original = new ExampleRecord("id-3", "sprocket", 42); - - ExampleRecord roundTripped = - ExampleMongoMapper.toDomain(ExampleMongoMapper.toDocument(original)); - - assertThat(roundTripped).isEqualTo(original); - } -} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java deleted file mode 100644 index aaaa2e0..0000000 --- a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java +++ /dev/null @@ -1,87 +0,0 @@ -package dev.caskeleton.adapter.outbound.mongo; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.mongodb.client.MongoClient; -import com.mongodb.client.MongoClients; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; -import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -/** - * Real MongoDB round-trip for {@link ExampleMongoRepositoryAdapter} against a Testcontainers - * MongoDB. The Spring Data repository proxy is built directly with {@link MongoRepositoryFactory} - * over a {@link MongoTemplate} — no full Spring context needed, mirroring how the object-storage - * MinIO IT constructs its adapter by hand. Skipped automatically when Docker is unavailable ({@code - * disabledWithoutDocker = true}); the pure mapping is covered separately by {@link - * ExampleMongoMapperTest}. - */ -@Testcontainers(disabledWithoutDocker = true) -class ExampleMongoRepositoryIT { - - private static final int MONGO_PORT = 27017; - private static final String DATABASE = "ca_skeleton_it"; - - @Container - @SuppressWarnings("resource") - static final GenericContainer MONGO = - new GenericContainer<>(DockerImageName.parse("mongo:7.0")) - .withExposedPorts(MONGO_PORT) - .waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 1)); - - private MongoClient client; - private ExampleMongoRepositoryAdapter adapter; - - @BeforeEach - void setUp() { - String uri = "mongodb://" + MONGO.getHost() + ":" + MONGO.getMappedPort(MONGO_PORT); - client = MongoClients.create(uri); - MongoTemplate template = - new MongoTemplate(new SimpleMongoClientDatabaseFactory(client, DATABASE)); - ExampleMongoRepository repository = - new MongoRepositoryFactory(template).getRepository(ExampleMongoRepository.class); - repository.deleteAll(); - adapter = new ExampleMongoRepositoryAdapter(repository); - } - - @AfterEach - void tearDown() { - if (client != null) { - client.close(); - } - } - - @Test - void savesAndReadsBackThroughTheDomainBoundary() { - ExampleRecord saved = adapter.save(new ExampleRecord("it-1", "widget", 5)); - assertThat(saved).isEqualTo(new ExampleRecord("it-1", "widget", 5)); - - Optional found = adapter.findById("it-1"); - assertThat(found).contains(new ExampleRecord("it-1", "widget", 5)); - } - - @Test - void findByNameUsesTheDerivedQueryCaseInsensitively() { - adapter.save(new ExampleRecord("it-2", "Gadget", 1)); - adapter.save(new ExampleRecord("it-3", "gadget", 2)); - - assertThat(adapter.findByName("GADGET")) - .extracting(ExampleRecord::id) - .containsExactlyInAnyOrder("it-2", "it-3"); - } - - @Test - void findByIdIsEmptyForAnAbsentId() { - assertThat(adapter.findById("absent")).isEmpty(); - assertThat(adapter.count()).isZero(); - } -} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfigTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfigTest.java new file mode 100644 index 0000000..17c178f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfigTest.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.mongodb.client.MongoClient; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.MongoTemplate; + +class MongoPersistenceConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(BootAutoConfigurationApp.class, MongoPersistenceConfig.class) + .withPropertyValues("spring.data.mongodb.database=portfolio"); + + @Test + void disabledByDefaultCreatesNoMongoInfrastructure() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(MongoClient.class); + assertThat(context).doesNotHaveBean(MongoTemplate.class); + }); + } + + @Test + void explicitlyDisabledCreatesNoMongoInfrastructure() { + runner + .withPropertyValues("ca-skeleton.persistence-mongo.enabled=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(MongoClient.class); + assertThat(context).doesNotHaveBean(MongoTemplate.class); + }); + } + + @Test + void enableFlagBindsThroughTypedProperties() { + new ApplicationContextRunner() + .withUserConfiguration(PropertiesOnly.class) + .withPropertyValues("ca-skeleton.persistence-mongo.enabled=true") + .run( + context -> { + assertThat(context).hasNotFailed(); + MongoPersistenceProperties properties = + context.getBean(MongoPersistenceProperties.class); + assertThat(properties.isEnabled()).isTrue(); + }); + } + + @Test + void enabledModeCreatesMongoTemplateWithoutExampleDomainBeans() { + MongoClient client = mock(MongoClient.class); + + runner + .withBean(MongoClient.class, () -> client) + .withPropertyValues( + "ca-skeleton.persistence-mongo.enabled=true", "spring.data.mongodb.database=portfolio") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(MongoTemplate.class); + assertThat( + Arrays.stream(context.getBeanDefinitionNames()) + .filter(name -> name.contains("example"))) + .isEmpty(); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(MongoPersistenceProperties.class) + static class PropertiesOnly {} + + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + static class BootAutoConfigurationApp {} +} diff --git a/src/adapter/outbound/support/CLAUDE.md b/src/adapter/outbound/support/CLAUDE.md index d07e548..850fda4 100644 --- a/src/adapter/outbound/support/CLAUDE.md +++ b/src/adapter/outbound/support/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `adapter-outbound-support` - Gradle path: `:adapter:outbound:support` -- Focused test: `./gradlew :adapter:outbound:support:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:support:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package roots: `dev.caskeleton.adapter.outbound` and `dev.caskeleton.adapter.outbound.support`. diff --git a/src/adapter/outbound/support/build.gradle b/src/adapter/outbound/support/build.gradle index 0048fd9..bf579e5 100644 --- a/src/adapter/outbound/support/build.gradle +++ b/src/adapter/outbound/support/build.gradle @@ -1,10 +1,6 @@ // Shared base for outbound integration adapters: correlation, fail-open dependency // logging, and the @Configuration seam. Depended on by messaging/cache/notification/httpclient. dependencies { - implementation project(':domain-core') - implementation project(':application-core') - implementation project(':shared-contract') - implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.slf4j:slf4j-api' } diff --git a/src/adapter/outbound/support/gradle.lockfile b/src/adapter/outbound/support/gradle.lockfile index f12183a..90cb88f 100644 --- a/src/adapter/outbound/support/gradle.lockfile +++ b/src/adapter/outbound/support/gradle.lockfile @@ -2,8 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor @@ -44,7 +44,7 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -60,9 +60,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -108,7 +108,7 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -120,13 +120,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -144,7 +144,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath diff --git a/src/app-bootstrap/CLAUDE.md b/src/app-bootstrap/CLAUDE.md index b622b62..36b3eb5 100644 --- a/src/app-bootstrap/CLAUDE.md +++ b/src/app-bootstrap/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `app-bootstrap` - Gradle path: `:app-bootstrap` -- Focused test: `./gradlew :app-bootstrap:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :app-bootstrap:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.bootstrap`. @@ -19,8 +19,8 @@ Package root: `dev.caskeleton.bootstrap`. ## Allowed -- Runtime leaves explicitly allowed by `.harness/project/modules.yaml`; do not duplicate the - 19-leaf dependency list here. +- Runtime leaves explicitly allowed by the `app-bootstrap` entry in + `src/config/architecture/modules.json`; do not duplicate the 19-leaf dependency list here. - Spring Boot startup/runtime dependencies. - ArchUnit in tests. diff --git a/src/app-bootstrap/README.md b/src/app-bootstrap/README.md index 402c5c5..c26b8a8 100644 --- a/src/app-bootstrap/README.md +++ b/src/app-bootstrap/README.md @@ -2,9 +2,17 @@ 애플리케이션 진입점이자 합성 루트(composition root) 모듈. 패키지 루트: `dev.caskeleton.bootstrap`. -이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 모듈 간 최종 -와이어링, 그리고 모든 모듈을 검사하는 아키텍처 테스트만 둔다. 허용/금지 의존, 책임 범위, 테스트 -명령 같은 **모듈 규칙**의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다. +이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 기본 runtime +모듈 간 최종 와이어링, 그리고 composition classpath를 대상으로 한 중앙 아키텍처 테스트만 둔다. +19개 leaf 전체의 프로젝트 edge는 JSON registry를 읽는 Gradle gate가 별도로 검사한다. 허용/금지 +의존, 책임 범위, 테스트 명령 같은 **모듈 규칙**의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다. + +기본 composition은 `build.gradle`에 선언된 runtime leaf만 포함한다. GraphQL, gRPC, WebSocket, +MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌드·테스트되지만 자동으로 +기본 애플리케이션에 합성되지 않는다. optional leaf를 활성화하려면 +`src/config/architecture/modules.json`의 `app-bootstrap.allowed_dependencies`에 허용 edge를 +명시하고, 같은 변경에서 `app-bootstrap/build.gradle` 의존성과 필요한 typed settings/검증을 +추가해야 한다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 본문은 한국어로 쓰고, 클래스·Spring API·메트릭 이름처럼 @@ -426,6 +434,9 @@ 하는데, `application-core`는 설정(`OutboxSettings`)을 직접 읽으면 안 된다. 그래서 설정을 볼 수 있는 합성 루트(`OutboxConfig`)가 값을 꺼내 use case 를 손으로 만들어 넘긴다. use case 클래스의 `@UseCaseCapability` 애너테이션은 와이어링 방식과 무관하게 유지된다(ArchUnit 이 강제). +- **bootstrap은 failure reporter를 구현하지 않고 주입만 한다.** `MessagingConfig`가 broker 설정에서 + 정확히 하나의 `OutboxRelayFailureReportPort` 구현을 만들고, `OutboxConfig`는 이를 relay 생성자에 + 전달한다. broker가 비활성이어도 reporter bean은 존재한다. - **릴레이 use case 를 독립 컨텍스트 빈으로 등록하지 않는다.** 만약 빈으로 올리면 `adapter-web`의 `MethodSecurityConfig` 메서드 보안 pointcut(`@RequiresPermission`)이 이 타입을 CGLIB 프록시로 감싼다. 그런데 use case 가 `final` 클래스라 프록시 생성 자체가 실패하고, 설령 된다 해도 스케줄러 @@ -466,8 +477,9 @@ 스케줄러 등록까지 같이 소유한다. - **릴레이 사이클에서 발생하는 예상치 못한 예외를 잡아 ERROR 로 로깅만 하고 삼킨다.** 스케줄러 스레드가 죽으면 릴레이가 조용히 멈추므로, 다음 틱을 위해 스레드를 살려둔다. 단, 개별 발행 실패 - (FAILED/DEAD 전이)는 릴레이 use case 내부에서 이미 상태 전이와 ERROR 로그로 처리되어 결과에 - 반영되므로 이 catch 블록까지 오지 않는다 — 여기서 삼키는 것은 어디까지나 "예상치 못한" 예외다. + (FAILED/DEAD 전이)는 relay가 persisted transition 성공 뒤 typed reporter로 canonical ERROR를 + 요청하고 결과에 반영하므로 이 catch 블록까지 오지 않는다 — 여기서 삼키는 것은 어디까지나 + "예상치 못한" 예외다. - **`@EnableScheduling`을 직접 켜지 않고 fixed-delay 를 쓴다.** 스케줄링은 이미 `IdempotencyConfig`를 통해 활성화돼 있어 중복으로 켤 필요가 없고, fixed-delay 는 릴레이 실행 시간과 무관하게 사이클이 겹치지 않도록(non-overlapping) 보장한다. diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index bc66a67..d4e2089 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -1,4 +1,5 @@ -// Application entry point. Wires every module together and runs Spring Boot. +// Application entry point. Wires the default runtime module set and runs Spring Boot. +// Optional leaves require an explicit registry allowance plus a composition-root dependency. apply plugin: 'org.springframework.boot' // The sample fixture (sampleFixture -> sample-portfolio -> objectstorage) pulls software.amazon.awssdk:s3 @@ -52,6 +53,7 @@ dependencies { implementation project(':shared-contract') implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-validation' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' implementation 'me.paulschwarz:spring-dotenv:4.0.0' // Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration // (MigrationStartupConfig). See README. diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index 122fb5b..ba407f5 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -30,7 +30,7 @@ com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,sampleOffTestAn com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.f4b6a3:uuid-creator:6.1.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.github.f4b6a3:uuid-creator:6.1.1=sampleFixture,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath @@ -93,6 +93,7 @@ io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath, io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.1.4=sampleFixture io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.15.12=sampleFixture @@ -109,20 +110,22 @@ io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.5.12=sampleFixture io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec-base:4.2.7.Final=testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-dns:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http2:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec-http:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec-marshalling:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec-protobuf:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec:4.2.7.Final=testRuntimeClasspath -io.netty:netty-common:4.2.7.Final=testRuntimeClasspath -io.netty:netty-handler:4.2.7.Final=testRuntimeClasspath -io.netty:netty-resolver:4.2.7.Final=testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport-classes-epoll:4.2.7.Final=testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.7.Final=testRuntimeClasspath -io.netty:netty-transport:4.2.7.Final=testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture @@ -167,9 +170,12 @@ io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeC io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.smallrye:jandex:3.2.0=sampleFixture -io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=sampleFixture +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.29=sampleFixture +io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.29=sampleFixture +io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -297,14 +303,17 @@ org.slf4j:jul-to-slf4j:2.0.18=sampleFixture org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=sampleFixture org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:2.8.6=sampleFixture +org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=sampleFixture +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -428,6 +437,7 @@ org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.4=sampleFixture org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java index c7c3768..982176a 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java @@ -2,6 +2,7 @@ package dev.caskeleton.bootstrap.outbox; import dev.caskeleton.application.outbox.OutboxBackoffPolicy; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; import dev.caskeleton.application.outbox.OutboxStorePort; import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; import dev.caskeleton.application.transaction.TransactionPort; @@ -36,6 +37,7 @@ public class OutboxConfig { public OutboxRelayScheduler outboxRelayScheduler( OutboxStorePort store, OutboxMessagePublishPort publishPort, + OutboxRelayFailureReportPort failureReporter, TransactionPort tx, Clock clock, RandomGenerator outboxRandomGenerator, @@ -45,6 +47,7 @@ public class OutboxConfig { new PublishPendingOutboxEventsUseCase( store, publishPort, + failureReporter, tx, new OutboxBackoffPolicy(outboxRandomGenerator), clock, diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java index 0049c42..7d4ce2b 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java @@ -31,6 +31,7 @@ public class SecretSourceValidator implements SmartInitializingSingleton { "APP_SECURITY_OAUTH_CLIENT_SECRET", "APP_EXTERNAL_API_KEY", "APP_CACHE_REDIS_PASSWORD", + "APP_CACHE_REDIS_KEY_HMAC_SECRET", "APP_PRIVACY_PSEUDONYMIZATION_SALT"); private final ConfigurableEnvironment environment; diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index dd7c736..c5b3329 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -470,6 +470,22 @@ app: redis: # true | false (boolean_strict). Redis cache adapter on/off. enabled: ${APP_CACHE_REDIS_ENABLED} + # managed = module-owned Lettuce runtime; external = project-supplied RedisClient bean. + client-mode: ${APP_CACHE_REDIS_CLIENT_MODE:managed} + host: ${APP_CACHE_REDIS_HOST:} + port: ${APP_CACHE_REDIS_PORT:6379} + password: ${APP_CACHE_REDIS_PASSWORD:} + # Base64-encoded, at least 32 decoded bytes. Required when the managed runtime is enabled. + key-hmac-secret: ${APP_CACHE_REDIS_KEY_HMAC_SECRET:} + command-timeout: ${APP_CACHE_REDIS_COMMAND_TIMEOUT:2s} + maximum-queued-commands: ${APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS:8} + maximum-in-flight-bytes: ${APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES:16777216} + positive-ttl: ${APP_CACHE_DEFAULT_TTL:300s} + negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} + semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} + maximum-value-bytes: ${APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES:1048576} # Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default — # forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis. # A binding to a backend that is not enabled fails startup (Layer 3 moved to router). @@ -501,6 +517,8 @@ app: read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT} # duration (e.g. 10s). REQUIRED — non-zero (deadline budget for the whole call incl. retries). global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT} + # Per-client live worker bound; timed-out non-cooperative workers retain a slot until exit. + maximum-in-flight-calls: ${APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS:128} # true | false (boolean_strict). Resilience4j retry — default disabled (D3). retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} # retry 튜닝 (retry-enabled=true 일 때 적용). 기본값 = 기존 하드코딩 동작 보존. diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java index 54303c5..e83c29b 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java @@ -18,6 +18,8 @@ import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher; import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig; import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender; import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter; +import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter; import dev.caskeleton.adapter.outbound.notification.NotificationConfig; import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier; @@ -30,6 +32,7 @@ import dev.caskeleton.application.notification.Channel; import dev.caskeleton.application.notification.Notification; import dev.caskeleton.application.notification.NotificationPort; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; import dev.caskeleton.shared.error.AdapterDisabledException; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -88,6 +91,9 @@ class OptionalAdapterBeanGatingTest { .isInstanceOf(DisabledMessagePublisher.class); assertThat(context.getBean(OutboxMessagePublishPort.class)) .isInstanceOf(DisabledOutboxMessagePublisher.class); + assertThat(context.getBeansOfType(OutboxRelayFailureReportPort.class)).hasSize(1); + assertThat(context.getBean(OutboxRelayFailureReportPort.class)) + .isInstanceOf(Slf4jOutboxRelayFailureReportAdapter.class); // cache D4: zero backends boot fine, unwired access fails fast in the router CacheStoreRouter cacheRouter = context.getBean(CacheStoreRouter.class); @@ -117,13 +123,21 @@ class OptionalAdapterBeanGatingTest { .isInstanceOf(OutboundMessagePublisher.class); assertThat(context.getBeansOfType(DisabledMessagePublisher.class)).isEmpty(); assertThat(context.getBeansOfType(DisabledOutboxMessagePublisher.class)).isEmpty(); + assertThat(context.getBean(OutboxMessagePublishPort.class)) + .isInstanceOf(OutboxMessagePublishAdapter.class); + assertThat(context.getBeansOfType(OutboxRelayFailureReportPort.class)).hasSize(1); + assertThat(context.getBean(OutboxRelayFailureReportPort.class)) + .isInstanceOf(Slf4jOutboxRelayFailureReportAdapter.class); }); } @Test void redisEnabledContributesTheBackendAndRoutesBoundLogicalCaches() { runner - .withPropertyValues("app.cache.redis.enabled=true", "app.cache.bindings.worklog=redis") + .withPropertyValues( + "app.cache.redis.enabled=true", + "app.cache.redis.client-mode=external", + "app.cache.bindings.worklog=redis") .run( context -> { assertThat(context).hasNotFailed(); @@ -176,6 +190,7 @@ class OptionalAdapterBeanGatingTest { .withUserConfiguration(SecondBackendConfig.class) .withPropertyValues( "app.cache.redis.enabled=true", + "app.cache.redis.client-mode=external", "app.cache.test-second.enabled=true", "app.cache.bindings.worklog=redis", "app.cache.bindings.session=test-second") diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/application/architecture/violations/ApplicationDiagnosticFrameworkViolation.java b/src/app-bootstrap/src/test/java/dev/caskeleton/application/architecture/violations/ApplicationDiagnosticFrameworkViolation.java new file mode 100644 index 0000000..9d73c3a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/application/architecture/violations/ApplicationDiagnosticFrameworkViolation.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.architecture.violations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Intentional SLF4J dependency used to prove the application diagnostic-framework ban fires. */ +public final class ApplicationDiagnosticFrameworkViolation { + + private static final Logger LOG = + LoggerFactory.getLogger(ApplicationDiagnosticFrameworkViolation.class); + + private ApplicationDiagnosticFrameworkViolation() {} + + public static void emit() { + LOG.info("intentional architecture violation fixture"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java index b82c95d..514af5d 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.EvaluationResult; +import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase; import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository; @@ -56,6 +57,8 @@ class ArchitectureViolationFixtureTest { new ClassFileImporter().importClasses(JakartaValidationDomainFixture.class); private static final JavaClasses VALIDATION_IN_APPLICATION_FIXTURE_ONLY = new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class); + private static final JavaClasses APPLICATION_DIAGNOSTIC_FRAMEWORK_FIXTURE_ONLY = + new ClassFileImporter().importClasses(ApplicationDiagnosticFrameworkViolation.class); // Each WebSocket fixture is imported in ISOLATION so the two package globs in // NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..") @@ -167,6 +170,19 @@ class ArchitectureViolationFixtureTest { .isTrue(); } + @Test + void applicationHasNoDiagnosticFrameworkCatchesSlf4jDependency() { + EvaluationResult result = + CleanArchitectureTest.APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK.evaluate( + APPLICATION_DIAGNOSTIC_FRAMEWORK_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK must catch " + + "ApplicationDiagnosticFrameworkViolation") + .isTrue(); + } + @Test void applicationDoesNotDependOnApplicationContextCatchesViolation() { EvaluationResult result = diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index a745a9c..f259fa9 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -214,6 +214,39 @@ class CleanArchitectureTest { "org.hibernate..") .allowEmptyShould(true); + @ArchTest + static final ArchRule APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK = + noClasses() + .that() + .resideInAPackage("dev.caskeleton.application..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "org.slf4j..", + "java.util.logging..", + "ch.qos.logback..", + "org.apache.logging.log4j..", + "io.micrometer..") + .as( + "APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK: application-core owns typed outbound " + + "diagnostic ports, while logging and metrics implementations belong to " + + "outbound adapters") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule SAMPLE_APPLICATION_HAS_NO_SLF4J = + noClasses() + .that() + .resideInAPackage("dev.caskeleton.sample.portfolio.application..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.slf4j..") + .as( + "SAMPLE_APPLICATION_HAS_NO_SLF4J: sample application collaborators read " + + "correlation context through application-core CorrelationIdPort; inbound " + + "adapters own MDC") + .allowEmptyShould(true); + @ArchTest static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION = noClasses() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java index 9c7fe86..7dbf377 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java @@ -60,7 +60,8 @@ class DeveloperExperienceContractTest { assertThat(readme) .contains("./gradlew bootstrap") .contains("GET /api/healthcheck") - .contains("feature-developer-experience-contract"); + .contains("## 퀵스타트") + .contains("첫 실행 진입점은 하나입니다."); } @Test diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java index 53e38e0..9f245d7 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java @@ -188,6 +188,7 @@ final class OutboxContainerTestSupport { return new PublishPendingOutboxEventsUseCase( store, publisher, + ignored -> {}, tx, new OutboxBackoffPolicy(RandomGenerator.getDefault()), clock, diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java index 8dc2708..830918a 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java @@ -579,6 +579,7 @@ class OutboxRowLifecycleContractTest { new PublishPendingOutboxEventsUseCase( store, event -> {}, + ignored -> {}, ctx.getBean(TransactionPort.class), new OutboxBackoffPolicy(RandomGenerator.getDefault()), reclaimClock, @@ -668,6 +669,7 @@ class OutboxRowLifecycleContractTest { return new PublishPendingOutboxEventsUseCase( ctx.getBean(OutboxStoreAdapter.class), ctx.getBean(OutboxMessagePublishPort.class), + ignored -> {}, ctx.getBean(TransactionPort.class), new OutboxBackoffPolicy(RandomGenerator.getDefault()), Clock.fixed(clockInstant, ZoneOffset.UTC), diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java index 2ffa916..0a6ecbc 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java @@ -29,6 +29,7 @@ class SecretSourceValidatorTest { "APP_SECURITY_OAUTH_CLIENT_SECRET=real-oauth-secret", "APP_EXTERNAL_API_KEY=real-api-key", "APP_CACHE_REDIS_PASSWORD=real-redis-password", + "APP_CACHE_REDIS_KEY_HMAC_SECRET=real-redis-key-hmac-secret", "APP_PRIVACY_PSEUDONYMIZATION_SALT=real-salt" }; } diff --git a/src/application-core/CLAUDE.md b/src/application-core/CLAUDE.md index 4c95dad..9365103 100644 --- a/src/application-core/CLAUDE.md +++ b/src/application-core/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `application-core` - Gradle path: `:application-core` -- Focused test: `./gradlew :application-core:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :application-core:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.application`. @@ -19,15 +19,14 @@ Package root: `dev.caskeleton.application`. - Application exceptions and policy types. - Coordinate domain models through ports. - Own application transaction boundaries through the `TransactionPort` abstraction. +- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own + MDC or other concrete storage. ## Allowed - `:domain-core` - `:shared-contract` -- `org.springframework.boot:spring-boot-starter` — so use cases may opt into - `@Service` / `@Component` DI registration (D13). Spring core (`spring-context` / - `spring-beans`) is intentionally kept on the compile classpath because the - alternative — manual `@Configuration` per use case — explodes boilerplate. +- Java standard library types. ## Forbidden @@ -45,6 +44,8 @@ Package root: `dev.caskeleton.application`. Lombok is currently not in scope for the contract; if you intend to use it, weigh the bytecode opacity cost first. - Persistence-layer transaction annotations of any kind inside this module. +- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express + diagnostic intent through a specific outbound `*Port`; adapters own rendering. ## Contract types @@ -71,7 +72,6 @@ Package root: `dev.caskeleton.application`. ## Canonical use case shape ```java -@Service @UseCaseCapability( transactionMode = TransactionMode.WRITE, idempotency = Idempotency.KEYED, @@ -95,6 +95,9 @@ public final class RegisterUserUseCase implements CommandUseCase= maxAttempts` 면 `markDead` + - `OUTBOX_DEAD_LETTER` ERROR 로그; 아니면 `markFailed(nextAttemptAt)` + - `OUTBOX_PUBLISH_FAILED` ERROR 로그. + - 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead`, 아니면 + `markFailed(nextAttemptAt)`를 먼저 성공시킨 뒤 해당 typed failure report를 보낸다. - **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을 - 구동하고 ERROR 로그를 낸 뒤 rethrow 하지 않는다(스케줄러 루프가 다음 이벤트로 계속 가야 - 하므로). 모든 발행 실패는 반드시 (a) 상태 전이와 (b) error code·correlationId·eventId·eventType· - attemptCount 를 담은 ERROR 로그를 **둘 다** 남긴다. 둘 중 하나라도 빠지면 금지된 silent-swallow. + 구동하고, 성공한 전이만 `OutboxRelayFailureReportPort`로 보고한 뒤 rethrow 하지 않는다 + (스케줄러 루프가 다음 이벤트로 계속 가야 하므로). 상태 전이가 실패하면 예외가 전파되고 report는 + 없다. reporter가 `RuntimeException`을 던져도 persisted outcome을 바꾸거나 다음 이벤트를 막지 + 못한다. +- **안전한 allowlist report**: `OutboxRelayFailureReport`는 + `code/eventId/eventType/aggregateId/correlationId/attemptCount/nextAttemptAt/cause`만 가진다. + payload, idempotency key, whole `OutboxEvent`, severity/template, arbitrary map은 타입 수준에서 + 전달할 수 없다. retry factory는 `OUTBOX_PUBLISH_FAILED`와 필수 `nextAttemptAt`, dead factory는 + `OUTBOX_DEAD_LETTER`와 null retry time을 고정한다. - **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라 에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를 dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아 @@ -372,6 +379,9 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장. - `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가 없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를 만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다. +- `OutboxRelayFailureReportPort` / `OutboxRelayFailureReport` — confirmed FAILED/DEAD 상태를 + adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은 + messaging adapter가 소유한다. --- @@ -462,6 +472,14 @@ application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapt ## 로그 가명화 포트 (observability) +### CorrelationIdPort + +- 현재 application invocation의 correlation id를 `Optional`으로 읽는 framework-free + 경계다. application/sample use case는 MDC나 SLF4J를 직접 알지 않는다. +- inbound web adapter가 sanitized `correlation_id` MDC 슬롯을 구현 세부로 읽는다. +- 값이 없거나 blank이면 event publisher는 생성한 event id를 correlation id로 재사용해 기존 + self-correlation 동작을 유지한다. + ### UserPrincipalPseudonymizerPort - raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound diff --git a/src/application-core/build.gradle b/src/application-core/build.gradle index ca016cb..a643938 100644 --- a/src/application-core/build.gradle +++ b/src/application-core/build.gradle @@ -1,15 +1,5 @@ -// Application use case contract. -// -// Depends only on the domain and operational contracts. spring-boot-starter is kept on -// the compile classpath so application use cases can opt into @Service registration -// without depending on transport / persistence frameworks. -// -// spring-tx is intentionally NOT declared: application code MUST NOT import -// `org.springframework.transaction.annotation.Transactional`. Use the -// `TransactionPort` abstraction. The CleanArchitectureTest ArchUnit suite enforces -// this for any module that resides under `..application..`. +// Framework-free application use-case contract. Runtime dependencies are project-only; +// composition and diagnostic rendering belong to adapters/bootstrap. dependencies { - implementation project(':domain-core') implementation project(':shared-contract') - implementation 'org.springframework.boot:spring-boot-starter' } diff --git a/src/application-core/gradle.lockfile b/src/application-core/gradle.lockfile index b131a71..ff6d49b 100644 --- a/src/application-core/gradle.lockfile +++ b/src/application-core/gradle.lockfile @@ -1,23 +1,17 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -31,27 +25,16 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs @@ -60,28 +43,22 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath @@ -91,61 +68,15 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath -empty= +empty=compileClasspath,runtimeClasspath diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java new file mode 100644 index 0000000..ec7ebc1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.cache; + +/** Business-classified source absence that is safe to negative-cache. */ +public enum AuthoritativeAbsence { + NOT_FOUND, + DELETED, + NOT_APPLICABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheInvalidationOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheInvalidationOutcome.java new file mode 100644 index 0000000..be7e639 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheInvalidationOutcome.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.cache; + +/** Provider-neutral invalidation result. */ +public enum CacheInvalidationOutcome { + INVALIDATED, + ALREADY_ABSENT, + DEGRADED_UNAVAILABLE, + INDETERMINATE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java new file mode 100644 index 0000000..c137516 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java @@ -0,0 +1,88 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Lookup result that never collapses provider failure, negative entries, and normal misses. */ +public sealed interface CacheLookup + permits CacheLookup.Hit, + CacheLookup.NegativeHit, + CacheLookup.Miss, + CacheLookup.IncompatibleSchema, + CacheLookup.Unavailable { + + record Hit(V value, Freshness freshness, String sourceRevision) implements CacheLookup { + + public Hit { + Objects.requireNonNull(value, "value must be non-null"); + Objects.requireNonNull(freshness, "freshness must be non-null"); + if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) { + throw new IllegalArgumentException("sourceRevision must contain 1..128 characters"); + } + } + } + + record NegativeHit(AuthoritativeAbsence reason) implements CacheLookup { + + public NegativeHit { + Objects.requireNonNull(reason, "reason must be non-null"); + } + } + + record Miss(MissReason reason) implements CacheLookup { + + public Miss { + Objects.requireNonNull(reason, "reason must be non-null"); + } + } + + record IncompatibleSchema(SchemaCategory category, SchemaPolicy policy) + implements CacheLookup { + + public IncompatibleSchema { + Objects.requireNonNull(category, "category must be non-null"); + Objects.requireNonNull(policy, "policy must be non-null"); + } + } + + record Unavailable(UnavailabilityReason reason, OperationCertainty certainty) + implements CacheLookup { + + public Unavailable { + Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(certainty, "certainty must be non-null"); + } + } + + enum Freshness { + FRESH, + STALE + } + + enum MissReason { + ABSENT, + EXPIRED, + INVALIDATED + } + + enum SchemaCategory { + FUTURE_VERSION, + RETIRED_VERSION, + UNKNOWN_ENVELOPE, + CORRUPT_ENVELOPE + } + + enum SchemaPolicy { + FAIL_FAST, + QUARANTINE_AND_RELOAD + } + + enum UnavailabilityReason { + UNAVAILABLE, + OVERLOADED + } + + enum OperationCertainty { + NOT_APPLIED, + INDETERMINATE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java new file mode 100644 index 0000000..68b699d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.cache; + +/** Application-visible consistency intent; technical TTL and codec remain provider policy. */ +public enum CacheRecordIntent { + UPSERT, + ONLY_IF_SOURCE_REVISION_NEWER +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java new file mode 100644 index 0000000..4fc1053 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Metadata derived from the authoritative source, never from a cache provider. */ +public record CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) { + + public CacheRecordMetadata { + if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) { + throw new IllegalArgumentException("sourceRevision must contain 1..128 characters"); + } + Objects.requireNonNull(intent, "intent must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordOutcome.java new file mode 100644 index 0000000..ac8f0f4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordOutcome.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.cache; + +/** Provider-neutral result of recording a positive or authoritative-negative entry. */ +public enum CacheRecordOutcome { + RECORDED, + NOT_RECORDED_CONDITION, + NOT_RECORDED_PROVIDER_POLICY, + DEGRADED_UNAVAILABLE, + INDETERMINATE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java new file mode 100644 index 0000000..3194c07 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.cache; + +/** + * Provider-neutral cache-region contract. Concrete use cases should extend this interface with a + * semantic port name and domain-specific key/value types. + */ +public interface CacheRegionPort { + + CacheLookup lookup(K key); + + CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata); + + CacheRecordOutcome recordAbsent(K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata); + + CacheInvalidationOutcome invalidate(K key); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java new file mode 100644 index 0000000..4410dcb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java @@ -0,0 +1,60 @@ +package dev.caskeleton.application.filepublication; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Ordered, versioned schema for tabular publication. */ +public record ExportSchema(String schemaId, int version, List columns) { + + public ExportSchema { + schemaId = FilePublicationValues.requireOpaque("schemaId", schemaId, 128); + if (version < 1) { + throw new IllegalArgumentException("schema version must be >= 1"); + } + if (columns == null || columns.isEmpty()) { + throw new IllegalArgumentException("schema columns must be non-empty"); + } + columns = List.copyOf(columns); + Set names = new HashSet<>(); + for (Column column : columns) { + Objects.requireNonNull(column, "schema column must be non-null"); + if (!names.add(column.name())) { + throw new IllegalArgumentException("duplicate schema column: " + column.name()); + } + } + } + + public record Column( + String name, + CellType cellType, + boolean nullable, + FormulaPolicy formulaPolicy, + int maximumUtf8Bytes) { + + public Column { + name = FilePublicationValues.requireOpaque("column name", name, 128); + Objects.requireNonNull(cellType, "cellType must be non-null"); + Objects.requireNonNull(formulaPolicy, "formulaPolicy must be non-null"); + if (maximumUtf8Bytes < 1) { + throw new IllegalArgumentException("maximumUtf8Bytes must be >= 1"); + } + } + } + + public enum CellType { + TEXT, + INTEGER, + DECIMAL, + BOOLEAN, + DATE, + INSTANT + } + + public enum FormulaPolicy { + ALLOW, + MITIGATE, + REJECT + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java new file mode 100644 index 0000000..441b091 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.filepublication; + +/** Registered logical file destination; never a path, URI, host, or provider identifier. */ +public record FileDestinationId(String value) { + + public FileDestinationId { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException("destinationId must match [a-z][a-z0-9-]{0,62}"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationException.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationException.java new file mode 100644 index 0000000..6b64414 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationException.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.filepublication; + +/** + * Provider-neutral publication failure. The reason is stable application-facing vocabulary; paths, + * credentials, and provider exception messages must not be embedded in it. + */ +public final class FilePublicationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final Reason reason; + + public FilePublicationException(Reason reason, String message) { + super(message); + this.reason = reason; + } + + public FilePublicationException(Reason reason, String message, Throwable cause) { + super(message, cause); + this.reason = reason; + } + + public Reason reason() { + return reason; + } + + public enum Reason { + INVALID_REQUEST, + CONFLICT, + CAPACITY_EXCEEDED, + UNAVAILABLE, + CANCELLED, + PUBLISH_INDETERMINATE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java new file mode 100644 index 0000000..b70cfb0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.filepublication; + +/** + * Outbound application port for publishing a bounded tabular artifact to a registered logical + * destination. + */ +public interface FilePublicationPort { + + FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationValues.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationValues.java new file mode 100644 index 0000000..aff6bee --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationValues.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.filepublication; + +final class FilePublicationValues { + + private FilePublicationValues() {} + + static String requireOpaque(String field, String value, int maximumLength) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must be non-null and non-blank"); + } + String normalized = value.trim(); + if (normalized.length() > maximumLength) { + throw new IllegalArgumentException(field + " exceeds " + maximumLength + " characters"); + } + if (normalized.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " must not contain control characters"); + } + return normalized; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java new file mode 100644 index 0000000..47c5549 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.filepublication; + +/** Stable opaque operation identity retained across resolution and retry. */ +public record FilePublishOperationId(String value) { + + public FilePublishOperationId { + value = FilePublicationValues.requireOpaque("operationId", value, 128); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java new file mode 100644 index 0000000..944ecf3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java @@ -0,0 +1,52 @@ +package dev.caskeleton.application.filepublication; + +import java.time.Instant; +import java.util.Objects; + +/** Sanitized publication receipt. It intentionally contains no local/remote path or credential. */ +public record FilePublishReceipt( + FilePublishOperationId operationId, + PublishedFileReference reference, + FileDestinationId destinationId, + String publishedFileName, + FileVersion version, + String formatProfileId, + String mediaType, + String charset, + long byteSize, + long dataRowCount, + int columnCount, + String sha256, + Instant publishedAt, + PublicationGuarantee publicationGuarantee, + DurabilityGuarantee durabilityGuarantee, + long formulaMitigatedCount) { + + public FilePublishReceipt { + Objects.requireNonNull(operationId, "operationId must be non-null"); + Objects.requireNonNull(reference, "reference must be non-null"); + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + publishedFileName = + FilePublicationValues.requireOpaque("publishedFileName", publishedFileName, 256); + Objects.requireNonNull(version, "version must be non-null"); + formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128); + mediaType = FilePublicationValues.requireOpaque("mediaType", mediaType, 128); + charset = FilePublicationValues.requireOpaque("charset", charset, 64); + sha256 = FilePublicationValues.requireOpaque("sha256", sha256, 64); + Objects.requireNonNull(publishedAt, "publishedAt must be non-null"); + Objects.requireNonNull(publicationGuarantee, "publicationGuarantee must be non-null"); + Objects.requireNonNull(durabilityGuarantee, "durabilityGuarantee must be non-null"); + if (byteSize < 0 || dataRowCount < 0 || columnCount < 1 || formulaMitigatedCount < 0) { + throw new IllegalArgumentException("receipt counts and sizes are out of range"); + } + } + + public enum PublicationGuarantee { + UNIQUE_ATOMIC_CREATE + } + + public enum DurabilityGuarantee { + PROCESS_LOCAL_SYNC, + PROVIDER_ACK_ONLY + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java new file mode 100644 index 0000000..47be9ca --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.filepublication; + +import java.util.Objects; + +/** Provider-neutral publication intent. */ +public record FilePublishRequest( + FilePublishOperationId operationId, + FileDestinationId destinationId, + LogicalFileName logicalFileName, + SourceRevision sourceRevision, + ExportSchema schema, + String formatProfileId) { + + public FilePublishRequest { + Objects.requireNonNull(operationId, "operationId must be non-null"); + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + Objects.requireNonNull(logicalFileName, "logicalFileName must be non-null"); + Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"); + Objects.requireNonNull(schema, "schema must be non-null"); + formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java new file mode 100644 index 0000000..8ba687f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.filepublication; + +/** Opaque immutable version of a published artifact. */ +public record FileVersion(String value) { + + public FileVersion { + value = FilePublicationValues.requireOpaque("fileVersion", value, 128); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java new file mode 100644 index 0000000..f0ed08c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.filepublication; + +/** Display/naming input that cannot carry filesystem path syntax. */ +public record LogicalFileName(String value) { + + public LogicalFileName { + value = FilePublicationValues.requireOpaque("logicalFileName", value, 128); + if (value.contains("/") || value.contains("\\") || value.equals(".") || value.equals("..")) { + throw new IllegalArgumentException("logicalFileName must not contain path syntax"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java new file mode 100644 index 0000000..f27f536 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.filepublication; + +/** Opaque reference that never exposes a filesystem path, host, or provider location. */ +public record PublishedFileReference(String value) { + + public PublishedFileReference { + value = FilePublicationValues.requireOpaque("publishedFileReference", value, 256); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java new file mode 100644 index 0000000..6993cd7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.filepublication; + +/** Stable source snapshot or source fingerprint selected by the application. */ +public record SourceRevision(String value) { + + public SourceRevision { + value = FilePublicationValues.requireOpaque("sourceRevision", value, 256); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java new file mode 100644 index 0000000..5dd025f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java @@ -0,0 +1,77 @@ +package dev.caskeleton.application.filepublication; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; + +/** Closed framework-free set of cells supported by the baseline tabular publisher. */ +public sealed interface TabularCell { + + ExportSchema.CellType cellType(); + + record TextCell(String value) implements TabularCell { + public TextCell { + Objects.requireNonNull(value, "text value must be non-null"); + } + + @Override + public ExportSchema.CellType cellType() { + return ExportSchema.CellType.TEXT; + } + } + + record IntegerCell(long value) implements TabularCell { + @Override + public ExportSchema.CellType cellType() { + return ExportSchema.CellType.INTEGER; + } + } + + record DecimalCell(BigDecimal value) implements TabularCell { + public DecimalCell { + Objects.requireNonNull(value, "decimal value must be non-null"); + } + + @Override + public ExportSchema.CellType cellType() { + return ExportSchema.CellType.DECIMAL; + } + } + + record BooleanCell(boolean value) implements TabularCell { + @Override + public ExportSchema.CellType cellType() { + return ExportSchema.CellType.BOOLEAN; + } + } + + record DateCell(LocalDate value) implements TabularCell { + public DateCell { + Objects.requireNonNull(value, "date value must be non-null"); + } + + @Override + public ExportSchema.CellType cellType() { + return ExportSchema.CellType.DATE; + } + } + + record InstantCell(Instant value) implements TabularCell { + public InstantCell { + Objects.requireNonNull(value, "instant value must be non-null"); + } + + @Override + public ExportSchema.CellType cellType() { + return ExportSchema.CellType.INSTANT; + } + } + + record NullCell() implements TabularCell { + @Override + public ExportSchema.CellType cellType() { + throw new IllegalStateException("null cells do not have a concrete cell type"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java new file mode 100644 index 0000000..f0ded27 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.filepublication; + +import java.util.List; +import java.util.Objects; + +/** Immutable ordered row. */ +public record TabularRow(List cells) { + + public TabularRow { + Objects.requireNonNull(cells, "cells must be non-null"); + cells = List.copyOf(cells); + if (cells.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("cells must not contain null; use NullCell"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java new file mode 100644 index 0000000..d4dd2d1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.filepublication; + +/** Synchronous single-attempt producer for bounded row-by-row publication. */ +@FunctionalInterface +public interface TabularRowProducer { + + void produce(TabularRowSink sink); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java new file mode 100644 index 0000000..f5b15c6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.filepublication; + +/** Attempt-scoped non-thread-safe sink owned by the file publication adapter. */ +public interface TabularRowSink { + + void write(TabularRow row); + + void checkpoint(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/observability/CorrelationIdPort.java b/src/application-core/src/main/java/dev/caskeleton/application/observability/CorrelationIdPort.java new file mode 100644 index 0000000..83dd02e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/observability/CorrelationIdPort.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.observability; + +import java.util.Optional; + +/** + * Reads the correlation identifier associated with the current application invocation. + * + *

Implementations own transport or diagnostic storage. They return {@link Optional#empty()} when + * no non-blank correlation identifier is available. + */ +@FunctionalInterface +public interface CorrelationIdPort { + + Optional currentCorrelationId(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java b/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java new file mode 100644 index 0000000..8db36f6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.outbound; + +import java.time.Duration; +import java.util.Objects; + +/** + * Absolute monotonic deadline carrier for nested application calls. It is process-local and must + * never be serialized as a wall-clock timestamp. + */ +public record CallBudget(long monotonicDeadlineNanos) { + + private static final Duration MAXIMUM_BUDGET = Duration.ofDays(365); + + public static CallBudget fromNow(Duration duration) { + return after(System.nanoTime(), duration); + } + + public static CallBudget after(long monotonicNowNanos, Duration duration) { + Objects.requireNonNull(duration, "duration must be non-null"); + if (duration.isZero() || duration.isNegative() || duration.compareTo(MAXIMUM_BUDGET) > 0) { + throw new IllegalArgumentException("call budget duration must be in (0, 365 days]"); + } + long durationNanos; + try { + durationNanos = duration.toNanos(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "call budget duration exceeds the supported range", exception); + } + return new CallBudget(monotonicNowNanos + durationNanos); + } + + public long remainingNanosAt(long monotonicNowNanos) { + long remaining = monotonicDeadlineNanos - monotonicNowNanos; + return remaining > 0 ? remaining : 0; + } + + public boolean isExpiredAt(long monotonicNowNanos) { + return monotonicDeadlineNanos - monotonicNowNanos <= 0; + } + + public CallBudget intersect(CallBudget other) { + Objects.requireNonNull(other, "other must be non-null"); + return monotonicDeadlineNanos - other.monotonicDeadlineNanos <= 0 ? this : other; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java new file mode 100644 index 0000000..f53f2d9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java @@ -0,0 +1,88 @@ +package dev.caskeleton.application.outbox; + +import dev.caskeleton.shared.error.OperationalError; +import java.time.Instant; +import java.util.Objects; + +/** + * Safe immutable allowlist for reporting a confirmed FAILED or DEAD outbox relay transition. + * + *

The report deliberately cannot carry an event payload, idempotency key, rendered message, + * severity, arbitrary fields, or the whole {@link OutboxEvent}. + */ +public record OutboxRelayFailureReport( + OperationalError code, + String eventId, + String eventType, + String aggregateId, + String correlationId, + int attemptCount, + Instant nextAttemptAt, + RuntimeException cause) { + + public OutboxRelayFailureReport { + Objects.requireNonNull(code, "code must not be null"); + requireNonBlank(eventId, "eventId"); + requireNonBlank(eventType, "eventType"); + requireNonBlank(aggregateId, "aggregateId"); + requireNonBlank(correlationId, "correlationId"); + Objects.requireNonNull(cause, "cause must not be null"); + if (attemptCount < 1) { + throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount); + } + if (code == OperationalError.OUTBOX_PUBLISH_FAILED) { + if (nextAttemptAt == null) { + throw new IllegalArgumentException("nextAttemptAt is required for OUTBOX_PUBLISH_FAILED"); + } + } else if (code == OperationalError.OUTBOX_DEAD_LETTER) { + if (nextAttemptAt != null) { + throw new IllegalArgumentException("nextAttemptAt is forbidden for OUTBOX_DEAD_LETTER"); + } + } else { + throw new IllegalArgumentException("unsupported outbox relay failure code: " + code); + } + } + + public static OutboxRelayFailureReport retryableFailure( + String eventId, + String eventType, + String aggregateId, + String correlationId, + int attemptCount, + Instant nextAttemptAt, + RuntimeException cause) { + return new OutboxRelayFailureReport( + OperationalError.OUTBOX_PUBLISH_FAILED, + eventId, + eventType, + aggregateId, + correlationId, + attemptCount, + nextAttemptAt, + cause); + } + + public static OutboxRelayFailureReport deadLetter( + String eventId, + String eventType, + String aggregateId, + String correlationId, + int attemptCount, + RuntimeException cause) { + return new OutboxRelayFailureReport( + OperationalError.OUTBOX_DEAD_LETTER, + eventId, + eventType, + aggregateId, + correlationId, + attemptCount, + null, + cause); + } + + private static void requireNonBlank(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java new file mode 100644 index 0000000..3967dc9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.outbox; + +/** + * Reports a confirmed outbox relay failure transition to an operational diagnostics adapter. + * + *

Implementations must not throw. Callers still defend against {@link RuntimeException} so a + * diagnostic failure can never change the authoritative persisted relay outcome. + */ +@FunctionalInterface +public interface OutboxRelayFailureReportPort { + + void report(OutboxRelayFailureReport report); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java index 45b4efe..c856df7 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java @@ -14,17 +14,17 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.function.Supplier; /** * Relay use case that claims pending outbox events and publishes them to the broker: claim a batch * in a short write transaction, sort by {@code occurredAt}, then publish each event * outside any transaction and drive the PUBLISHED / FAILED / DEAD state machine per - * result. Publish failures are logged and never rethrown; a status-update failure after a - * successful publish propagates and the row is recovered via the in-flight timeout. Wired manually - * by {@code app-bootstrap} (not a Spring bean). See README for the full algorithm, failure - * semantics, manual-wiring rationale, and the {@code "outbox:relay"} permission. + * result. Confirmed failure transitions are reported through a typed outbound port and publish + * failures are never rethrown; a status-update failure after a successful publish propagates and + * the row is recovered via the in-flight timeout. Wired manually by {@code app-bootstrap} (not a + * Spring bean). See README for the full algorithm, failure semantics, manual-wiring rationale, and + * the {@code "outbox:relay"} permission. */ @RequiresPermission("outbox:relay") @UseCaseCapability( @@ -35,11 +35,9 @@ import org.slf4j.LoggerFactory; public final class PublishPendingOutboxEventsUseCase implements CommandUseCase { - private static final Logger log = - LoggerFactory.getLogger(PublishPendingOutboxEventsUseCase.class); - private final OutboxStorePort store; private final OutboxMessagePublishPort publishPort; + private final OutboxRelayFailureReportPort failureReporter; private final TransactionPort tx; private final OutboxBackoffPolicy backoffPolicy; private final Clock clock; @@ -52,6 +50,7 @@ public final class PublishPendingOutboxEventsUseCase * * @param store outbox store port (claim + status update) * @param publishPort fail-closed broker publish port + * @param failureReporter diagnostics port for confirmed FAILED/DEAD transitions * @param tx transaction port for short write boundaries * @param backoffPolicy retry backoff policy * @param clock wall-clock source (injected for testability) @@ -61,6 +60,7 @@ public final class PublishPendingOutboxEventsUseCase public PublishPendingOutboxEventsUseCase( OutboxStorePort store, OutboxMessagePublishPort publishPort, + OutboxRelayFailureReportPort failureReporter, TransactionPort tx, OutboxBackoffPolicy backoffPolicy, Clock clock, @@ -68,6 +68,8 @@ public final class PublishPendingOutboxEventsUseCase Duration inFlightTimeout) { this.store = Objects.requireNonNull(store, "store must not be null"); this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null"); + this.failureReporter = + Objects.requireNonNull(failureReporter, "failureReporter must not be null"); this.tx = Objects.requireNonNull(tx, "tx must not be null"); this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); @@ -116,7 +118,7 @@ public final class PublishPendingOutboxEventsUseCase try { publishPort.publish(event); } catch (RuntimeException publishEx) { - // Publish failure: drive FAILED/DEAD state machine + ERROR log; do NOT rethrow. + // Publish failure: drive FAILED/DEAD state machine + typed report; do NOT rethrow. return handlePublishFailure(event, now, publishEx); } // markPublished failure (if any) propagates: the row stays IN_FLIGHT and is @@ -126,9 +128,9 @@ public final class PublishPendingOutboxEventsUseCase } /** - * Drives the FAILED/DEAD state transition and produces a mandatory ERROR log — always both a - * status transition and an ERROR log (omitting either is the forbidden silent-swallow). See - * README. + * Drives the FAILED/DEAD state transition and reports it only after persistence succeeds. A + * transition failure remains authoritative and propagates without a report. A diagnostic adapter + * failure is contained and cannot change the persisted outcome. See README. */ private OutboxRelayResult.Outcome handlePublishFailure( OutboxEvent event, Instant now, RuntimeException cause) { @@ -136,34 +138,39 @@ public final class PublishPendingOutboxEventsUseCase if (event.attemptCount() >= backoffPolicy.maxAttempts()) { // All attempts exhausted — DEAD-letter the event. tx.inWrite(() -> store.markDead(event.eventId())); - log.error( - "error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} " - + "— outbox event dead-lettered after {} attempts; manual intervention required", - "OUTBOX_DEAD_LETTER", - event.eventId(), - event.eventType(), - event.aggregateId(), - event.correlationId(), - event.attemptCount(), - backoffPolicy.maxAttempts(), - cause); + reportFailure( + () -> + OutboxRelayFailureReport.deadLetter( + event.eventId(), + event.eventType(), + event.aggregateId(), + event.correlationId(), + event.attemptCount(), + cause)); return OutboxRelayResult.Outcome.DEAD; } else { // Transient failure — schedule retry with exponential backoff. Instant nextAttemptAt = backoffPolicy.nextAttemptAt(event.attemptCount(), now); tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt)); - log.error( - "error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} " - + "nextAttemptAt={} — outbox publish failed transiently; will retry", - "OUTBOX_PUBLISH_FAILED", - event.eventId(), - event.eventType(), - event.aggregateId(), - event.correlationId(), - event.attemptCount(), - nextAttemptAt, - cause); + reportFailure( + () -> + OutboxRelayFailureReport.retryableFailure( + event.eventId(), + event.eventType(), + event.aggregateId(), + event.correlationId(), + event.attemptCount(), + nextAttemptAt, + cause)); return OutboxRelayResult.Outcome.FAILED; } } + + private void reportFailure(Supplier reportFactory) { + try { + failureReporter.report(reportFactory.get()); + } catch (RuntimeException ignored) { + // Report construction and delivery are non-authoritative. The persisted transition remains. + } + } } diff --git a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java new file mode 100644 index 0000000..3874718 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class CacheRegionContractTest { + + @Test + void keepsMissNegativeHitAndUnavailableDistinct() { + CacheLookup miss = new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT); + CacheLookup negative = new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND); + CacheLookup unavailable = + new CacheLookup.Unavailable<>( + CacheLookup.UnavailabilityReason.OVERLOADED, + CacheLookup.OperationCertainty.NOT_APPLIED); + + assertThat(miss).isInstanceOf(CacheLookup.Miss.class); + assertThat(negative).isInstanceOf(CacheLookup.NegativeHit.class); + assertThat(unavailable).isInstanceOf(CacheLookup.Unavailable.class); + } + + @Test + void hitCarriesFreshnessAndSourceRevisionWithoutProviderTypes() { + CacheLookup.Hit hit = + new CacheLookup.Hit<>("snapshot", CacheLookup.Freshness.STALE, "source-42"); + + assertThat(hit.value()).isEqualTo("snapshot"); + assertThat(hit.freshness()).isEqualTo(CacheLookup.Freshness.STALE); + assertThat(hit.sourceRevision()).isEqualTo("source-42"); + } + + @Test + void metadataRejectsBlankRevision() { + assertThatThrownBy( + () -> new CacheRecordMetadata(" ", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java new file mode 100644 index 0000000..573417e --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java @@ -0,0 +1,99 @@ +package dev.caskeleton.application.filepublication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class FilePublicationContractTest { + + @Test + void logicalFileNameRejectsPathSyntax() { + assertThatThrownBy(() -> new LogicalFileName("../report.csv")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LogicalFileName("nested/report.csv")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LogicalFileName("nested\\report.csv")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void schemaRejectsDuplicateColumnNames() { + assertThatThrownBy( + () -> + new ExportSchema( + "worklog-v1", + 1, + List.of( + new ExportSchema.Column( + "id", + ExportSchema.CellType.INTEGER, + false, + ExportSchema.FormulaPolicy.REJECT, + 64), + new ExportSchema.Column( + "id", + ExportSchema.CellType.TEXT, + false, + ExportSchema.FormulaPolicy.MITIGATE, + 128)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + } + + @Test + void schemaDefensivelyCopiesColumns() { + ExportSchema schema = + new ExportSchema( + "worklog-v1", + 1, + List.of( + new ExportSchema.Column( + "id", + ExportSchema.CellType.INTEGER, + false, + ExportSchema.FormulaPolicy.REJECT, + 64))); + + assertThat(schema.columns()).hasSize(1); + assertThatThrownBy( + () -> + schema + .columns() + .add( + new ExportSchema.Column( + "other", + ExportSchema.CellType.TEXT, + true, + ExportSchema.FormulaPolicy.MITIGATE, + 128))) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void requestRequiresRegisteredIdentifiersAndFormatProfile() { + ExportSchema schema = + new ExportSchema( + "worklog-v1", + 1, + List.of( + new ExportSchema.Column( + "id", + ExportSchema.CellType.INTEGER, + false, + ExportSchema.FormulaPolicy.REJECT, + 64))); + + FilePublishRequest request = + new FilePublishRequest( + new FilePublishOperationId("01J1234567890ABCDEFGHJKMNP"), + new FileDestinationId("local-export"), + new LogicalFileName("worklogs"), + new SourceRevision("snapshot-42"), + schema, + "csv-rfc4180-v1"); + + assertThat(request.formatProfileId()).isEqualTo("csv-rfc4180-v1"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/observability/CorrelationIdPortTest.java b/src/application-core/src/test/java/dev/caskeleton/application/observability/CorrelationIdPortTest.java new file mode 100644 index 0000000..f55b6f1 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/observability/CorrelationIdPortTest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class CorrelationIdPortTest { + + @Test + void exposesPresentCorrelationIdWithoutAFrameworkType() { + CorrelationIdPort port = () -> Optional.of("corr-123"); + + assertThat(port.currentCorrelationId()).contains("corr-123"); + } + + @Test + void exposesAbsenceExplicitly() { + CorrelationIdPort port = Optional::empty; + + assertThat(port.currentCorrelationId()).isEmpty(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java new file mode 100644 index 0000000..0d2e2df --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.outbound; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class CallBudgetTest { + + @Test + void measuresRemainingTimeInTheMonotonicDomain() { + CallBudget budget = CallBudget.after(1_000, Duration.ofNanos(250)); + + assertThat(budget.remainingNanosAt(1_100)).isEqualTo(150); + assertThat(budget.isExpiredAt(1_249)).isFalse(); + assertThat(budget.isExpiredAt(1_250)).isTrue(); + assertThat(budget.remainingNanosAt(1_300)).isZero(); + } + + @Test + void childBudgetCannotOutliveItsParent() { + CallBudget parent = CallBudget.after(1_000, Duration.ofNanos(200)); + CallBudget longerChild = CallBudget.after(1_050, Duration.ofNanos(500)); + + assertThat(parent.intersect(longerChild)).isEqualTo(parent); + } + + @Test + void rejectsNonPositiveAndUnreasonablyLargeDurations() { + assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ofDays(366))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java new file mode 100644 index 0000000..914fb66 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java @@ -0,0 +1,147 @@ +package dev.caskeleton.application.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.error.OperationalError; +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class OutboxRelayFailureReportTest { + + private static final Instant NEXT_ATTEMPT_AT = Instant.parse("2026-07-25T01:02:03Z"); + private static final RuntimeException CAUSE = new RuntimeException("broker unavailable"); + + @Test + void recordComponentsAreAnExactSafeAllowlist() { + assertThat( + Arrays.stream(OutboxRelayFailureReport.class.getRecordComponents()) + .map(RecordComponent::getName)) + .containsExactly( + "code", + "eventId", + "eventType", + "aggregateId", + "correlationId", + "attemptCount", + "nextAttemptAt", + "cause"); + } + + @Test + void retryableFailureFactoryCreatesPublishFailedReport() { + OutboxRelayFailureReport report = + OutboxRelayFailureReport.retryableFailure( + "evt-1", "WorkLogReserved", "agg-1", "corr-1", 2, NEXT_ATTEMPT_AT, CAUSE); + + assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED); + assertThat(report.eventId()).isEqualTo("evt-1"); + assertThat(report.eventType()).isEqualTo("WorkLogReserved"); + assertThat(report.aggregateId()).isEqualTo("agg-1"); + assertThat(report.correlationId()).isEqualTo("corr-1"); + assertThat(report.attemptCount()).isEqualTo(2); + assertThat(report.nextAttemptAt()).isEqualTo(NEXT_ATTEMPT_AT); + assertThat(report.cause()).isSameAs(CAUSE); + } + + @Test + void deadLetterFactoryCreatesTerminalReportWithoutRetryTime() { + OutboxRelayFailureReport report = + OutboxRelayFailureReport.deadLetter( + "evt-1", "WorkLogReserved", "agg-1", "corr-1", 3, CAUSE); + + assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER); + assertThat(report.nextAttemptAt()).isNull(); + assertThat(report.cause()).isSameAs(CAUSE); + } + + @Test + void rejectsUnsupportedCode() { + assertThatThrownBy( + () -> + new OutboxRelayFailureReport( + OperationalError.INTERNAL_ERROR, + "evt-1", + "Event", + "agg-1", + "corr-1", + 1, + null, + CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("code"); + } + + @Test + void rejectsBlankIdentifiersAndEventType() { + assertThatThrownBy( + () -> OutboxRelayFailureReport.deadLetter(" ", "Event", "agg-1", "corr-1", 1, CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("eventId"); + assertThatThrownBy( + () -> OutboxRelayFailureReport.deadLetter("evt-1", "", "agg-1", "corr-1", 1, CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("eventType"); + assertThatThrownBy( + () -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "\t", "corr-1", 1, CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aggregateId"); + assertThatThrownBy( + () -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "\n", 1, CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("correlationId"); + } + + @Test + void rejectsAttemptCountBelowOne() { + assertThatThrownBy( + () -> + OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "corr-1", 0, CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attemptCount"); + } + + @Test + void retryableFailureRequiresNextAttemptAt() { + assertThatThrownBy( + () -> + new OutboxRelayFailureReport( + OperationalError.OUTBOX_PUBLISH_FAILED, + "evt-1", + "Event", + "agg-1", + "corr-1", + 1, + null, + CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nextAttemptAt"); + } + + @Test + void deadLetterForbidsNextAttemptAt() { + assertThatThrownBy( + () -> + new OutboxRelayFailureReport( + OperationalError.OUTBOX_DEAD_LETTER, + "evt-1", + "Event", + "agg-1", + "corr-1", + 1, + NEXT_ATTEMPT_AT, + CAUSE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nextAttemptAt"); + } + + @Test + void causeIsRequired() { + assertThatThrownBy( + () -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "corr-1", 1, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("cause"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java index 8a2ae94..f7730f9 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java @@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test; *

  • Successful transition: IN_FLIGHT → PUBLISHED *
  • Transient failure → FAILED + backoff window *
  • 3 attempt exhaustion → DEAD - *
  • Failure is NOT swallowed (status transition + ERROR log required) + *
  • Failure is NOT swallowed (status transition + typed report attempt required) *
  • Events processed in {@code occurredAt} ascending order * */ @@ -38,6 +38,7 @@ class PublishPendingOutboxEventsUseCaseTest { private FakeOutboxStorePort store; private FakeOutboxMessagePublishPort publishPort; + private RecordingFailureReporter reporter; private FakeTransactionPort tx; private OutboxBackoffPolicy backoffPolicy; private Clock clock; @@ -47,13 +48,14 @@ class PublishPendingOutboxEventsUseCaseTest { void setUp() { store = new FakeOutboxStorePort(); publishPort = new FakeOutboxMessagePublishPort(); + reporter = new RecordingFailureReporter(); tx = new FakeTransactionPort(); clock = Clock.fixed(NOW, ZoneOffset.UTC); // Use fixed random for determinism: always returns 0.0 jitter (nextDouble() = 0.0) backoffPolicy = new OutboxBackoffPolicy(new ZeroRandom()); useCase = new PublishPendingOutboxEventsUseCase( - store, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); + store, publishPort, reporter, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); } // ---- success path ---- @@ -71,6 +73,7 @@ class PublishPendingOutboxEventsUseCaseTest { .isEqualTo(OutboxRelayResult.Outcome.PUBLISHED); assertThat(store.publishedEvents).containsExactly("evt-1"); assertThat(publishPort.publishedEvents).containsExactly("evt-1"); + assertThat(reporter.reports).isEmpty(); } @Test @@ -106,6 +109,16 @@ class PublishPendingOutboxEventsUseCaseTest { assertThat(nextAttemptAt).isAfter(NOW); // Not yet DEAD (attempt 1 < maxAttempts 3) assertThat(store.deadEvents).doesNotContain("evt-fail"); + assertThat(reporter.reports) + .containsExactly( + OutboxRelayFailureReport.retryableFailure( + "evt-fail", + "UserCreated", + "agg-1", + "corr-evt-fail", + 1, + nextAttemptAt, + publishPort.failureOn("evt-fail"))); } @Test @@ -120,6 +133,15 @@ class PublishPendingOutboxEventsUseCaseTest { assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.DEAD); assertThat(store.deadEvents).contains("evt-dead"); assertThat(store.failedEvents).doesNotContainKey("evt-dead"); + assertThat(reporter.reports) + .containsExactly( + OutboxRelayFailureReport.deadLetter( + "evt-dead", + "UserCreated", + "agg-1", + "corr-evt-dead", + 3, + publishPort.failureOn("evt-dead"))); } // ---- failure NOT swallowed ---- @@ -207,7 +229,14 @@ class PublishPendingOutboxEventsUseCaseTest { PublishPendingOutboxEventsUseCase useCaseWithThrowingStore = new PublishPendingOutboxEventsUseCase( - throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); + throwingStore, + publishPort, + reporter, + tx, + backoffPolicy, + clock, + BATCH_SIZE, + IN_FLIGHT_TIMEOUT); // The exception must propagate — handle() must throw. assertThatThrownBy( @@ -225,6 +254,7 @@ class PublishPendingOutboxEventsUseCaseTest { assertThat(throwingStore.deadEvents) .as("markDead must NOT be called when only markPublished fails") .doesNotContain("evt-store-fail"); + assertThat(reporter.reports).isEmpty(); } /** @@ -248,7 +278,14 @@ class PublishPendingOutboxEventsUseCaseTest { PublishPendingOutboxEventsUseCase useCaseWithThrowingStore = new PublishPendingOutboxEventsUseCase( - throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); + throwingStore, + publishPort, + reporter, + tx, + backoffPolicy, + clock, + BATCH_SIZE, + IN_FLIGHT_TIMEOUT); assertThatThrownBy( () -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE)) @@ -266,6 +303,110 @@ class PublishPendingOutboxEventsUseCaseTest { // No FAILED or DEAD misclassification for the second event either. assertThat(throwingStore.failedEvents).doesNotContainKey("evt-second"); assertThat(throwingStore.deadEvents).doesNotContain("evt-second"); + assertThat(reporter.reports).isEmpty(); + } + + @Test + void markFailedFailurePropagatesAndEmitsNoReport() { + OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); + ThrowingTransitionStorePort throwingStore = + new ThrowingTransitionStorePort(new RuntimeException("markFailed failed"), null); + throwingStore.addClaimable(event); + publishPort.failOn("evt-fail", new RuntimeException("broker down")); + PublishPendingOutboxEventsUseCase throwingUseCase = + new PublishPendingOutboxEventsUseCase( + throwingStore, + publishPort, + reporter, + tx, + backoffPolicy, + clock, + BATCH_SIZE, + IN_FLIGHT_TIMEOUT); + + assertThatThrownBy(() -> throwingUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE)) + .hasMessage("markFailed failed"); + assertThat(reporter.reports).isEmpty(); + } + + @Test + void markDeadFailurePropagatesAndEmitsNoReport() { + OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3); + ThrowingTransitionStorePort throwingStore = + new ThrowingTransitionStorePort(null, new RuntimeException("markDead failed")); + throwingStore.addClaimable(event); + publishPort.failOn("evt-dead", new RuntimeException("broker down")); + PublishPendingOutboxEventsUseCase throwingUseCase = + new PublishPendingOutboxEventsUseCase( + throwingStore, + publishPort, + reporter, + tx, + backoffPolicy, + clock, + BATCH_SIZE, + IN_FLIGHT_TIMEOUT); + + assertThatThrownBy(() -> throwingUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE)) + .hasMessage("markDead failed"); + assertThat(reporter.reports).isEmpty(); + } + + @Test + void throwingReporterPreservesFailedOutcomeAndRelayContinues() { + OutboxEvent failed = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(120), 1); + OutboxEvent succeeded = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1); + store.addClaimable(failed); + store.addClaimable(succeeded); + publishPort.failOn("evt-fail", new RuntimeException("broker down")); + OutboxRelayFailureReportPort throwingReporter = + ignored -> { + throw new RuntimeException("reporter failed"); + }; + PublishPendingOutboxEventsUseCase useCaseWithThrowingReporter = + new PublishPendingOutboxEventsUseCase( + store, + publishPort, + throwingReporter, + tx, + backoffPolicy, + clock, + BATCH_SIZE, + IN_FLIGHT_TIMEOUT); + + OutboxRelayResult result = + useCaseWithThrowingReporter.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.outcomes()) + .extracting(OutboxRelayResult.EventOutcome::outcome) + .containsExactly(OutboxRelayResult.Outcome.FAILED, OutboxRelayResult.Outcome.PUBLISHED); + assertThat(store.failedEvents).containsKey("evt-fail"); + assertThat(store.publishedEvents).containsExactly("evt-ok"); + } + + @Test + void malformedReportDataPreservesFailedAndDeadOutcomesAndRelayContinues() { + OutboxEvent failed = makeEvent("evt-fail", "UserCreated", " ", NOW.minusSeconds(180), 1); + OutboxEvent dead = makeEvent("evt-dead", "UserDeleted", " ", NOW.minusSeconds(120), 3); + OutboxEvent succeeded = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1); + store.addClaimable(failed); + store.addClaimable(dead); + store.addClaimable(succeeded); + publishPort.failOn("evt-fail", new RuntimeException("transient broker failure")); + publishPort.failOn("evt-dead", new RuntimeException("persistent broker failure")); + + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.outcomes()) + .extracting(OutboxRelayResult.EventOutcome::outcome) + .containsExactly( + OutboxRelayResult.Outcome.FAILED, + OutboxRelayResult.Outcome.DEAD, + OutboxRelayResult.Outcome.PUBLISHED); + assertThat(store.failedEvents).containsKey("evt-fail"); + assertThat(store.deadEvents).containsExactly("evt-dead"); + assertThat(store.publishedEvents).containsExactly("evt-ok"); + assertThat(reporter.reports).isEmpty(); } // ---- helper ---- @@ -346,6 +487,33 @@ class PublishPendingOutboxEventsUseCaseTest { } } + static final class ThrowingTransitionStorePort extends FakeOutboxStorePort { + private final RuntimeException markFailedException; + private final RuntimeException markDeadException; + + ThrowingTransitionStorePort( + RuntimeException markFailedException, RuntimeException markDeadException) { + this.markFailedException = markFailedException; + this.markDeadException = markDeadException; + } + + @Override + public void markFailed(String eventId, Instant nextAttemptAt) { + if (markFailedException != null) { + throw markFailedException; + } + super.markFailed(eventId, nextAttemptAt); + } + + @Override + public void markDead(String eventId) { + if (markDeadException != null) { + throw markDeadException; + } + super.markDead(eventId); + } + } + static final class FakeOutboxMessagePublishPort implements OutboxMessagePublishPort { final List publishedEvents = new ArrayList<>(); private final Map failureMap = new LinkedHashMap<>(); @@ -354,6 +522,10 @@ class PublishPendingOutboxEventsUseCaseTest { failureMap.put(eventId, ex); } + RuntimeException failureOn(String eventId) { + return failureMap.get(eventId); + } + @Override public void publish(OutboxEvent event) { if (failureMap.containsKey(event.eventId())) { @@ -363,6 +535,15 @@ class PublishPendingOutboxEventsUseCaseTest { } } + static final class RecordingFailureReporter implements OutboxRelayFailureReportPort { + final List reports = new ArrayList<>(); + + @Override + public void report(OutboxRelayFailureReport report) { + reports.add(report); + } + } + static final class FakeTransactionPort implements TransactionPort { @Override public T inWrite(Supplier action) { diff --git a/src/build.gradle b/src/build.gradle index 9fbd646..ead48df 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,5 +1,6 @@ import groovy.json.JsonSlurper import org.gradle.api.artifacts.dsl.LockMode +import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Jar @@ -232,8 +233,13 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { } dependencies { - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + if (project.path in [':domain-core', ':application-core', ':shared-contract']) { + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' + } else { + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + } testRuntimeOnly 'org.junit.platform:junit-platform-launcher' spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0' // D4 code-level security @@ -555,7 +561,7 @@ tasks.register('verifyCleanArchitectureDependencies') { group = 'verification' description = 'Verifies Clean Architecture project dependency direction.' - File moduleRegistryFile = new File(rootProject.projectDir.parentFile, '.harness/project/modules.yaml') + File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') inputs.file(moduleRegistryFile) doLast { @@ -619,18 +625,140 @@ tasks.register('verifyCleanArchitectureDependencies') { } .toSet() + if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) { + throw new GradleException( + "Module ':${moduleName}' has a forbidden production dependency on " + + "':sample-portfolio'. The sample module may only be consumed through " + + "non-production fixture configurations." + ) + } + Set forbidden = actual - allowed if (!forbidden.isEmpty()) { throw new GradleException( "Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " + "Allowed dependencies are ${allowed}. " + - "Production modules must not depend on ':sample-ticket', and adapter modules must not depend on each other." + "Production modules must not depend on ':sample-portfolio'; " + + "all project edges must be explicitly registered." ) } } } } +def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') { + group = 'verification' + description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.' + + doLast { + Project application = project(':application-core') + List violations = [] + + ['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName -> + def configuration = application.configurations.findByName(configurationName) + if (configuration == null) { + return + } + configuration.dependencies.each { dependency -> + if (!(dependency instanceof ProjectDependency)) { + violations << "${configurationName}: non-project production dependency " + + "${dependency.group ?: ''}:${dependency.name}" + } + } + } + + Closure forbiddenGroup = { String groupName -> + groupName != null && ( + groupName.startsWith('org.springframework') || + groupName == 'org.slf4j' || + groupName == 'ch.qos.logback' || + groupName == 'org.apache.logging.log4j' || + groupName == 'io.micrometer') + } + ['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath'] + .each { configurationName -> + def configuration = application.configurations.getByName(configurationName) + configuration.incoming.resolutionResult.allComponents.each { component -> + if (component.id instanceof ModuleComponentIdentifier && + forbiddenGroup(component.id.group)) { + violations << "${configurationName}: forbidden resolved dependency " + + "${component.id.group}:${component.id.module}:${component.id.version}" + } + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " + + violations.toSorted().join('\n ')) + } + logger.lifecycle( + 'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.') + } +} + +project(':application-core').tasks.named('check') { + dependsOn verifyApplicationCoreDependencyPurity +} + +def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') { + group = 'verification' + description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.' + + File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') + inputs.file(moduleRegistryFile) + + doLast { + def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile) + List violations = [] + def processorDeclaration = ~/^\s*annotationProcessor\s+['"]org\.springframework\.boot:spring-boot-configuration-processor['"]\s*$/ + + moduleRegistry.modules.each { module -> + File leafDirectory = rootProject.projectDir.parentFile.toPath() + .resolve(module.source_path as String) + .normalize() + .toFile() + File mainSource = new File(leafDirectory, 'src/main') + File buildFile = new File(leafDirectory, 'build.gradle') + + int propertyAnnotationCount = 0 + if (mainSource.isDirectory()) { + mainSource.eachFileRecurse { File sourceFile -> + if (sourceFile.name.endsWith('.java')) { + propertyAnnotationCount += sourceFile.text.count('@ConfigurationProperties(') + } + } + } + int processorCount = buildFile.readLines().count { String line -> + processorDeclaration.matcher(line).matches() + } + + boolean ownsConfigurationProperties = propertyAnnotationCount > 0 + if (ownsConfigurationProperties && processorCount != 1) { + violations << "${module.id}: ${propertyAnnotationCount} @ConfigurationProperties occurrence(s), " + + "but ${processorCount} configuration-processor declaration(s)" + } else if (!ownsConfigurationProperties && processorCount != 0) { + violations << "${module.id}: no @ConfigurationProperties occurrence, but " + + "${processorCount} configuration-processor declaration(s)" + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyConfigurationPropertiesProcessor: ${violations.size()} parity violation(s):\n " + + violations.toSorted().join('\n ')) + } + logger.lifecycle( + "verifyConfigurationPropertiesProcessor: OK — all ${moduleRegistry.modules.size()} registered leaves have exact configuration-processor parity.") + } +} + +configure(subprojects.findAll { it.childProjects.isEmpty() }) { + tasks.named('check') { + dependsOn verifyConfigurationPropertiesProcessor + } +} + // verifyOneTypePerFile — one public top-level type per file, file name == type name // (code-conventions I6). Rationale in README.md. tasks.register('verifyOneTypePerFile') { diff --git a/src/config/architecture/modules.json b/src/config/architecture/modules.json new file mode 100644 index 0000000..3575ada --- /dev/null +++ b/src/config/architecture/modules.json @@ -0,0 +1,197 @@ +{ + "modules": [ + { + "id": "domain-core", + "gradle_path": ":domain-core", + "source_path": "src/domain-core", + "allowed_dependencies": [] + }, + { + "id": "shared-contract", + "gradle_path": ":shared-contract", + "source_path": "src/shared-contract", + "allowed_dependencies": [] + }, + { + "id": "application-core", + "gradle_path": ":application-core", + "source_path": "src/application-core", + "allowed_dependencies": [ + "domain-core", + "shared-contract" + ] + }, + { + "id": "adapter-outbound-support", + "gradle_path": ":adapter:outbound:support", + "source_path": "src/adapter/outbound/support", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-outbound-persistence-jpa", + "gradle_path": ":adapter:outbound:persistence-jpa", + "source_path": "src/adapter/outbound/persistence-jpa", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-outbound-persistence-mongo", + "gradle_path": ":adapter:outbound:persistence-mongo", + "source_path": "src/adapter/outbound/persistence-mongo", + "allowed_dependencies": [ + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-outbound-identifier", + "gradle_path": ":adapter:outbound:identifier", + "source_path": "src/adapter/outbound/identifier", + "allowed_dependencies": [ + "domain-core", + "application-core" + ] + }, + { + "id": "adapter-outbound-fileserver", + "gradle_path": ":adapter:outbound:fileserver", + "source_path": "src/adapter/outbound/fileserver", + "allowed_dependencies": [ + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-outbound-objectstorage", + "gradle_path": ":adapter:outbound:objectstorage", + "source_path": "src/adapter/outbound/objectstorage", + "allowed_dependencies": [ + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-outbound-cache-redis", + "gradle_path": ":adapter:outbound:cache-redis", + "source_path": "src/adapter/outbound/cache-redis", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract", + "adapter-outbound-support" + ] + }, + { + "id": "adapter-outbound-httpclient", + "gradle_path": ":adapter:outbound:httpclient", + "source_path": "src/adapter/outbound/httpclient", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract", + "adapter-outbound-support" + ] + }, + { + "id": "adapter-outbound-messaging", + "gradle_path": ":adapter:outbound:messaging", + "source_path": "src/adapter/outbound/messaging", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract", + "adapter-outbound-support" + ] + }, + { + "id": "adapter-outbound-notification", + "gradle_path": ":adapter:outbound:notification", + "source_path": "src/adapter/outbound/notification", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract", + "adapter-outbound-support" + ] + }, + { + "id": "adapter-inbound-web", + "gradle_path": ":adapter:inbound:web", + "source_path": "src/adapter/inbound/web", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-inbound-grpc", + "gradle_path": ":adapter:inbound:grpc", + "source_path": "src/adapter/inbound/grpc", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-inbound-graphql", + "gradle_path": ":adapter:inbound:graphql", + "source_path": "src/adapter/inbound/graphql", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract" + ] + }, + { + "id": "adapter-inbound-websocket", + "gradle_path": ":adapter:inbound:websocket", + "source_path": "src/adapter/inbound/websocket", + "allowed_dependencies": [ + "domain-core", + "application-core", + "shared-contract" + ] + }, + { + "id": "app-bootstrap", + "gradle_path": ":app-bootstrap", + "source_path": "src/app-bootstrap", + "allowed_dependencies": [ + "domain-core", + "application-core", + "adapter-outbound-persistence-jpa", + "adapter-outbound-support", + "adapter-outbound-messaging", + "adapter-outbound-cache-redis", + "adapter-outbound-notification", + "adapter-outbound-httpclient", + "adapter-outbound-identifier", + "adapter-inbound-web", + "shared-contract" + ] + }, + { + "id": "sample-portfolio", + "gradle_path": ":sample-portfolio", + "source_path": "src/sample-portfolio", + "allowed_dependencies": [ + "domain-core", + "application-core", + "adapter-outbound-persistence-jpa", + "adapter-outbound-identifier", + "adapter-outbound-objectstorage", + "adapter-inbound-web", + "shared-contract" + ] + } + ] +} diff --git a/src/domain-core/CLAUDE.md b/src/domain-core/CLAUDE.md index 8cc5881..7e32ff1 100644 --- a/src/domain-core/CLAUDE.md +++ b/src/domain-core/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `domain-core` - Gradle path: `:domain-core` -- Focused test: `./gradlew :domain-core:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :domain-core:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.domain`. diff --git a/src/domain-core/gradle.lockfile b/src/domain-core/gradle.lockfile index d74791d..ff6d49b 100644 --- a/src/domain-core/gradle.lockfile +++ b/src/domain-core/gradle.lockfile @@ -1,23 +1,17 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -31,27 +25,16 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs @@ -60,28 +43,22 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath @@ -91,61 +68,15 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath empty=compileClasspath,runtimeClasspath diff --git a/src/sample-portfolio/CLAUDE.md b/src/sample-portfolio/CLAUDE.md index 045a490..1a88a57 100644 --- a/src/sample-portfolio/CLAUDE.md +++ b/src/sample-portfolio/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `sample-portfolio` - Gradle path: `:sample-portfolio` -- Focused test: `./gradlew :sample-portfolio:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :sample-portfolio:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.sample.portfolio`. @@ -22,11 +22,13 @@ Package root: `dev.caskeleton.sample.portfolio`. `RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`). - Contract-test fixtures the app-bootstrap verification suite analyses (`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests). +- Sample application collaborators consume invocation context through application-core ports and + must not import SLF4J/MDC. ## Allowed -- Runtime leaves explicitly allowed for this fixture consumer by - `.harness/project/modules.yaml`; do not duplicate the 19-leaf list here. +- Runtime leaves explicitly allowed for this fixture consumer by the `sample-portfolio` entry in + `src/config/architecture/modules.json`; do not duplicate the 19-leaf list here. ## Forbidden diff --git a/src/sample-portfolio/README.md b/src/sample-portfolio/README.md index 1c74c13..2351fac 100644 --- a/src/sample-portfolio/README.md +++ b/src/sample-portfolio/README.md @@ -161,9 +161,10 @@ curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{ application) → ③ 직접 만든 JSON 페이로드로 직렬화 → ④ 같은 트랜잭션에서 outbox 에 append. - `eventId` 는 `OutboxEventIdFactory`(UUIDv7) 로 만들고, `idempotencyKey = eventId` 로 둡니다 (이벤트 단위 중복 제거). -- `correlationId` 는 MDC 의 `correlation_id` 슬롯에서 읽습니다(인바운드 HTTP 필터가 채워줌). - 스케줄러/배치/테스트처럼 그 필터를 거치지 않는 경로에서는 값이 없으므로 `eventId` 로 자기 - 자신을 가리키게(self-correlation) 폴백합니다. +- `correlationId` 는 application-core의 `CorrelationIdPort`로 읽습니다. inbound web adapter가 + sanitized MDC 값을 포트 뒤에서 제공하므로 sample application 코드는 SLF4J/MDC를 알지 않습니다. + 요청 밖에서 실행되거나 값이 blank면 `eventId`를 그대로 `correlationId`로 사용해 이벤트가 + 최소한 자신을 가리키게(self-correlation) 폴백합니다. - 쓰기 작업이라 `@RequiresPermission("worklog:write")` 로 권한을 요구합니다(이 권한은 `user`/`admin` 역할 묶음에 부여). diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle index ad4fccb..f078688 100644 --- a/src/sample-portfolio/build.gradle +++ b/src/sample-portfolio/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' // UUIDv7 generation (id factory) + UUID/String conversion (persistence mapper, web path). implementation 'com.github.f4b6a3:uuid-creator:6.1.1' // PATCH 3-state (absent / explicit-null / value) via JsonNullable. See README. diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile index 4241444..61fe4e8 100644 --- a/src/sample-portfolio/gradle.lockfile +++ b/src/sample-portfolio/gradle.lockfile @@ -106,9 +106,9 @@ io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClass io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath @@ -210,11 +210,12 @@ org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:2.8.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java index 6705767..58c24ea 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java @@ -1,5 +1,6 @@ package dev.caskeleton.sample.portfolio.application.event; +import dev.caskeleton.application.observability.CorrelationIdPort; import dev.caskeleton.application.outbox.NewOutboxEvent; import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.sample.portfolio.domain.poster.PosterArchived; @@ -10,7 +11,6 @@ import dev.caskeleton.sample.portfolio.domain.poster.PosterPublished; import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; import java.time.Clock; import java.time.Instant; -import org.slf4j.MDC; import org.springframework.stereotype.Component; /** @@ -18,22 +18,25 @@ import org.springframework.stereotype.Component; * a use case's {@code TransactionPort.inWrite(...)} block so the append participates in the same * write transaction (no dual-write). Factors out the outbox plumbing shown once in {@code * CreateWorkLogUseCase}, because Poster emits five lifecycle events. {@code eventId} (a UUIDv7 from - * {@link OutboxEventIdFactory}) doubles as the idempotency key; {@code correlationId} comes from - * MDC, falling back to {@code eventId} off-HTTP. + * {@link OutboxEventIdFactory}) doubles as the idempotency key; {@code correlationId} comes through + * {@link CorrelationIdPort}, falling back to {@code eventId} off-HTTP. */ @Component public class PosterEventPublisher { - private static final String MDC_CORRELATION_ID = "correlation_id"; - private final OutboxAppendPort outbox; private final OutboxEventIdFactory eventIdFactory; + private final CorrelationIdPort correlationIdPort; private final Clock clock; public PosterEventPublisher( - OutboxAppendPort outbox, OutboxEventIdFactory eventIdFactory, Clock clock) { + OutboxAppendPort outbox, + OutboxEventIdFactory eventIdFactory, + CorrelationIdPort correlationIdPort, + Clock clock) { this.outbox = outbox; this.eventIdFactory = eventIdFactory; + this.correlationIdPort = correlationIdPort; this.clock = clock; } @@ -69,10 +72,8 @@ public class PosterEventPublisher { private void append(String eventType, String aggregateId, String payload) { String eventId = eventIdFactory.newEventId(); - String correlationId = MDC.get(MDC_CORRELATION_ID); - if (correlationId == null || correlationId.isBlank()) { - correlationId = eventId; - } + String correlationId = + correlationIdPort.currentCorrelationId().filter(value -> !value.isBlank()).orElse(eventId); outbox.append( new NewOutboxEvent( eventId, eventType, aggregateId, payload, Instant.now(clock), correlationId, eventId)); diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java index b01bf26..50b6f14 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.sample.portfolio.application.worklog; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.observability.CorrelationIdPort; import dev.caskeleton.application.outbox.NewOutboxEvent; import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.security.RequiresPermission; @@ -21,14 +22,13 @@ import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; import java.time.Clock; import java.time.Instant; -import org.slf4j.MDC; import org.springframework.stereotype.Service; /** * Creates a new {@link WorkLog} and appends a {@link WorkLogReserved} outbox event in the same * write transaction, demonstrating the dual-write prohibition. {@code eventId} is the UUIDv7 from - * {@link OutboxEventIdFactory} and doubles as the idempotency key; {@code correlationId} comes from - * MDC. See README. + * {@link OutboxEventIdFactory} and doubles as the idempotency key; {@code correlationId} comes + * through {@link CorrelationIdPort}. See README. */ @Service @RequiresPermission("worklog:write") @@ -38,12 +38,11 @@ import org.springframework.stereotype.Service; repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) public class CreateWorkLogUseCase implements CommandUseCase { - private static final String MDC_CORRELATION_ID = "correlation_id"; - private final WorkLogRepository repository; private final WorkLogIdFactory idFactory; private final OutboxEventIdFactory eventIdFactory; private final OutboxAppendPort outboxAppendPort; + private final CorrelationIdPort correlationIdPort; private final Clock clock; private final TransactionPort tx; @@ -52,12 +51,14 @@ public class CreateWorkLogUseCase implements CommandUseCase !value.isBlank()).orElse(eventId); outboxAppendPort.append( new NewOutboxEvent( diff --git a/src/sample-portfolio/src/main/resources/application.yml b/src/sample-portfolio/src/main/resources/application.yml index ee302dc..8910d12 100644 --- a/src/sample-portfolio/src/main/resources/application.yml +++ b/src/sample-portfolio/src/main/resources/application.yml @@ -218,6 +218,20 @@ app: cache: redis: enabled: ${APP_CACHE_REDIS_ENABLED:false} + client-mode: ${APP_CACHE_REDIS_CLIENT_MODE:managed} + host: ${APP_CACHE_REDIS_HOST:} + port: ${APP_CACHE_REDIS_PORT:6379} + password: ${APP_CACHE_REDIS_PASSWORD:} + key-hmac-secret: ${APP_CACHE_REDIS_KEY_HMAC_SECRET:} + command-timeout: ${APP_CACHE_REDIS_COMMAND_TIMEOUT:2s} + maximum-queued-commands: ${APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS:8} + maximum-in-flight-bytes: ${APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES:16777216} + positive-ttl: ${APP_CACHE_DEFAULT_TTL:300s} + negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} + semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} + maximum-value-bytes: ${APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES:1048576} messaging: broker: ${APP_MESSAGING_BROKER:} kafka: @@ -232,6 +246,7 @@ app: connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT:2s} read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT:5s} global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT:10s} + maximum-in-flight-calls: ${APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS:128} retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} retry: max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java index 6625ebe..62d9b0b 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.fail; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; +import dev.caskeleton.adapter.inbound.web.config.OpenApiContractConfig; import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; @@ -166,6 +167,6 @@ class OpenApiDriftContractTest { DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class }) - @Import(WorkLogController.class) + @Import({WorkLogController.class, OpenApiContractConfig.class}) static class OpenApiDriftTestApp {} } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java index 8388a87..e41b580 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java @@ -2,6 +2,7 @@ package dev.caskeleton.sample.portfolio.adapter.inbound.web.openapi; import static org.assertj.core.api.Assertions.assertThat; +import dev.caskeleton.adapter.inbound.web.config.OpenApiContractConfig; import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; @@ -27,6 +28,8 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Import; import org.springframework.http.ResponseEntity; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * feature-api-contract-baseline D10 — OpenAPI producer pin. Boots a real Tomcat with springdoc on @@ -46,6 +49,8 @@ class OpenApiSnapshotTest { @Autowired TestRestTemplate rest; + @Autowired ObjectMapper objectMapper; + // The controller is the OpenAPI source; its collaborators are mocked so the slice // needs no datasource / security. @MockitoBean CreateWorkLogUseCase createUseCase; @@ -57,7 +62,7 @@ class OpenApiSnapshotTest { @MockitoBean GetRepoStatsUseCase repoStatsUseCase; @Test - void apiDocsAreGeneratedAndDescribeTheWorklogsContract() { + void apiDocsAreGeneratedAndDescribeTheWorklogsContract() throws Exception { ResponseEntity response = rest.getForEntity("http://localhost:" + port + "/v3/api-docs", String.class); @@ -69,6 +74,19 @@ class OpenApiSnapshotTest { assertThat(body).contains("\"openapi\""); // OAS 3.x document assertThat(body).contains("/worklogs"); // real controller surface, not a stale schema assertThat(body).contains("/worklogs:batchCreate"); // AIP-136 colon-verb custom method (D23) + + JsonNode apiErrorDetailsSchema = + objectMapper + .readTree(body) + .path("components") + .path("schemas") + .path("ApiError") + .path("properties") + .path("details"); + JsonNode apiErrorDetailsType = apiErrorDetailsSchema.get("type"); + assertThat(apiErrorDetailsType == null ? null : apiErrorDetailsType.asString()) + .as("the public ApiError.details contract must remain an object schema") + .isEqualTo("object"); } @SpringBootConfiguration @@ -81,6 +99,6 @@ class OpenApiSnapshotTest { DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class }) - @Import(WorkLogController.class) + @Import({WorkLogController.class, OpenApiContractConfig.class}) static class OpenApiTestApp {} } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java new file mode 100644 index 0000000..431a01d --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.observability.CorrelationIdPort; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class PosterEventPublisherTest { + + private static final PosterId POSTER_ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final String EVENT_ID = "0190bd6e-7c3e-7abc-8def-0123456789ff"; + private static final OutboxEventIdFactory EVENT_IDS = () -> EVENT_ID; + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2025-06-01T10:00:00Z"), ZoneOffset.UTC); + + @Test + void usesCorrelationIdPortWhenPresent() { + CapturingOutbox outbox = new CapturingOutbox(); + + publish(outbox, () -> Optional.of("corr-123")); + + assertThat(outbox.event.correlationId()).isEqualTo("corr-123"); + } + + @Test + void fallsBackToEventIdWhenCorrelationIdIsBlank() { + CapturingOutbox outbox = new CapturingOutbox(); + + publish(outbox, () -> Optional.of(" ")); + + assertThat(outbox.event.correlationId()).isEqualTo(EVENT_ID); + } + + @Test + void fallsBackToEventIdWhenCorrelationIdIsAbsent() { + CapturingOutbox outbox = new CapturingOutbox(); + + publish(outbox, Optional::empty); + + assertThat(outbox.event.correlationId()).isEqualTo(EVENT_ID); + } + + private static void publish(CapturingOutbox outbox, CorrelationIdPort correlationIdPort) { + new PosterEventPublisher(outbox, EVENT_IDS, correlationIdPort, CLOCK) + .publishCreated(new PosterCreated(POSTER_ID, "title")); + } + + private static final class CapturingOutbox implements OutboxAppendPort { + private NewOutboxEvent event; + + @Override + public void append(NewOutboxEvent event) { + this.event = event; + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java index 7e102c1..df0b2dd 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java @@ -2,6 +2,7 @@ package dev.caskeleton.sample.portfolio.application.worklog; import static org.assertj.core.api.Assertions.assertThat; +import dev.caskeleton.application.observability.CorrelationIdPort; import dev.caskeleton.application.outbox.NewOutboxEvent; import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; @@ -24,7 +25,6 @@ import java.util.List; import java.util.Optional; import java.util.function.Supplier; import org.junit.jupiter.api.Test; -import org.slf4j.MDC; /** * Verifies that {@link CreateWorkLogUseCase} appends a {@code WorkLogReserved} outbox event inside @@ -43,6 +43,7 @@ class CreateWorkLogOutboxTest { static final OutboxEventIdFactory EVENT_IDS = () -> FIXED_EVENT_ID; static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-06-01T10:00:00Z"), ZoneOffset.UTC); + static final CorrelationIdPort NO_CORRELATION = Optional::empty; static class FakeRepo implements WorkLogRepository { final List store = new ArrayList<>(); @@ -139,7 +140,8 @@ class CreateWorkLogOutboxTest { CapturingOutboxPort outbox = new CapturingOutboxPort(); TrackingTx tx = new TrackingTx(outbox); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); assertThat(outbox.appended).hasSize(1); assertThat(outbox.appended.get(0).eventType()) @@ -153,7 +155,8 @@ class CreateWorkLogOutboxTest { CapturingOutboxPort outbox = new CapturingOutboxPort(); TrackingTx tx = new TrackingTx(outbox); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); assertThat(tx.appendHappenedInsideTx) .as("OutboxAppendPort.append must be called inside tx.inWrite (D2)") @@ -167,7 +170,8 @@ class CreateWorkLogOutboxTest { TransactionPort tx = plainTx(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); assertThat(outbox.appended).hasSize(1); assertThat(outbox.appended.get(0).aggregateId()).isEqualTo(created.id().value()); @@ -180,7 +184,8 @@ class CreateWorkLogOutboxTest { CapturingOutboxPort outbox = new CapturingOutboxPort(); TransactionPort tx = plainTx(); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); NewOutboxEvent event = outbox.appended.get(0); assertThat(event.idempotencyKey()) @@ -194,7 +199,8 @@ class CreateWorkLogOutboxTest { CapturingOutboxPort outbox = new CapturingOutboxPort(); TransactionPort tx = plainTx(); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); assertThat(outbox.appended.get(0).occurredAt()) .isEqualTo(Instant.parse("2025-06-01T10:00:00Z")); @@ -206,7 +212,8 @@ class CreateWorkLogOutboxTest { CapturingOutboxPort outbox = new CapturingOutboxPort(); TransactionPort tx = plainTx(); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); String payload = outbox.appended.get(0).payload(); assertThat(payload).contains(FIXED_ID.value()); @@ -214,36 +221,47 @@ class CreateWorkLogOutboxTest { } @Test - void createOutboxEventCorrelationIdFallsBackToEventIdWhenNoMdc() { - // When no MDC correlation_id is present (non-HTTP path), correlationId = eventId + void createOutboxEventCorrelationIdFallsBackToEventIdWhenContextIsAbsent() { FakeRepo repo = new FakeRepo(); CapturingOutboxPort outbox = new CapturingOutboxPort(); TransactionPort tx = plainTx(); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) + .handle(createCmd()); NewOutboxEvent event = outbox.appended.get(0); assertThat(event.correlationId()) - .as("self-correlation fallback: correlationId = eventId when MDC absent") + .as("self-correlation fallback: correlationId = eventId when context is absent") .isEqualTo(event.eventId()); } @Test - void createOutboxEventUsesMdcCorrelationIdWhenPresent() { + void createOutboxEventUsesCorrelationIdPortWhenPresent() { FakeRepo repo = new FakeRepo(); CapturingOutboxPort outbox = new CapturingOutboxPort(); TransactionPort tx = plainTx(); - MDC.put("correlation_id", "test-corr-123"); - try { - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); - } finally { - MDC.remove("correlation_id"); - } + CorrelationIdPort correlationIdPort = () -> Optional.of("test-corr-123"); + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, correlationIdPort, FIXED_CLOCK, tx) + .handle(createCmd()); assertThat(outbox.appended.get(0).correlationId()).isEqualTo("test-corr-123"); } + @Test + void createOutboxEventFallsBackToEventIdWhenCorrelationIdPortReturnsBlank() { + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + CorrelationIdPort correlationIdPort = () -> Optional.of(" "); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, correlationIdPort, FIXED_CLOCK, tx) + .handle(createCmd()); + + NewOutboxEvent event = outbox.appended.get(0); + assertThat(event.correlationId()).isEqualTo(event.eventId()); + } + // Plain pass-through tx (no tracking) private static TransactionPort plainTx() { return new TransactionPort() { diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java index 3ee8d6a..a132016 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.application.observability.CorrelationIdPort; import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; @@ -91,6 +92,8 @@ class WorkLogUseCasesTest { /** No-op outbox port — existing tests focus on use-case behaviour, not outbox wiring. */ static final OutboxAppendPort NO_OP_OUTBOX = e -> {}; + static final CorrelationIdPort NO_CORRELATION = Optional::empty; + /** Deterministic event-id stub for existing tests. */ static final OutboxEventIdFactory STUB_EVENT_IDS = () -> "01FAKEEVENTIDTSV4RRFFQ6900"; @@ -113,7 +116,8 @@ class WorkLogUseCasesTest { void createThenGetReturnsSaved() { FakeRepo repo = new FakeRepo(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) .handle(createCmd("DB 튜닝")); WorkLog got = new GetWorkLogUseCase(repo, TX).handle(new GetWorkLogQuery(created.id())); assertThat(got.title()).isEqualTo("DB 튜닝"); @@ -123,7 +127,8 @@ class WorkLogUseCasesTest { void createSetsOwnerFromCommandPrincipal() { FakeRepo repo = new FakeRepo(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) .handle( new CreateWorkLogCommand( "sub-7", @@ -149,7 +154,8 @@ class WorkLogUseCasesTest { void updateAppliesPatchThreeState() { FakeRepo repo = new FakeRepo(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) .handle(createCmd("old")); WorkLog updated = new UpdateWorkLogUseCase(repo, TX) @@ -169,7 +175,8 @@ class WorkLogUseCasesTest { void updateCanAdvanceStatusButClosedWorklogRejectsLaterMutation() { FakeRepo repo = new FakeRepo(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) .handle(createCmd("status demo")); WorkLog inProgress = new UpdateWorkLogUseCase(repo, TX) @@ -213,7 +220,8 @@ class WorkLogUseCasesTest { void updateRejectsRevertingStatusToOpen() { FakeRepo repo = new FakeRepo(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) .handle(createCmd("status revert")); WorkLog inProgress = new UpdateWorkLogUseCase(repo, TX) @@ -246,7 +254,8 @@ class WorkLogUseCasesTest { void listReturnsPageWithTotal() { FakeRepo repo = new FakeRepo(); CreateWorkLogUseCase create = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX); + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX); create.handle(createCmd("a")); create.handle(createCmd("b")); var page = @@ -269,7 +278,8 @@ class WorkLogUseCasesTest { void updateExplicitNullClearsNullableField() { FakeRepo repo = new FakeRepo(); WorkLog created = - new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + new CreateWorkLogUseCase( + repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) .handle(createCmd("t")); WorkLog updated = new UpdateWorkLogUseCase(repo, TX) diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java index 48460e3..20b7b6c 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java @@ -13,6 +13,7 @@ import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.application.observability.CorrelationIdPort; import dev.caskeleton.application.outbox.NewOutboxEvent; import dev.caskeleton.application.outbox.OutboxAppendPort; import dev.caskeleton.application.transaction.TransactionPort; @@ -30,6 +31,7 @@ import java.time.Clock; import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.function.Supplier; import org.junit.jupiter.api.AfterEach; @@ -204,16 +206,22 @@ class WorkLogAuthorizationContractTest { return Clock.systemUTC(); } + @Bean + CorrelationIdPort correlationIdPort() { + return Optional::empty; + } + @Bean CreateWorkLogUseCase createWorkLogUseCase( WorkLogRepository repository, WorkLogIdFactory idFactory, OutboxEventIdFactory eventIdFactory, OutboxAppendPort outboxAppendPort, + CorrelationIdPort correlationIdPort, Clock clock, TransactionPort tx) { return new CreateWorkLogUseCase( - repository, idFactory, eventIdFactory, outboxAppendPort, clock, tx); + repository, idFactory, eventIdFactory, outboxAppendPort, correlationIdPort, clock, tx); } @Bean diff --git a/src/settings.gradle b/src/settings.gradle index 6fd4b8b..57556aa 100644 --- a/src/settings.gradle +++ b/src/settings.gradle @@ -6,21 +6,120 @@ plugins { rootProject.name = 'ca-skeleton' -File repositoryRoot = settingsDir.parentFile -File moduleRegistryFile = new File(repositoryRoot, '.harness/project/modules.yaml') +File repositoryRoot = settingsDir.parentFile.canonicalFile +File moduleRegistryFile = new File(settingsDir, 'config/architecture/modules.json') if (!moduleRegistryFile.isFile()) { throw new GradleException("Missing module registry: ${moduleRegistryFile}") } def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile) +if (!(moduleRegistry instanceof Map)) { + throw new GradleException("Module registry root must be a JSON object: ${moduleRegistryFile}") +} if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) { throw new GradleException("Module registry has no modules: ${moduleRegistryFile}") } -moduleRegistry.modules.each { module -> - if (!(module.gradle_path instanceof String) || !(module.source_path instanceof String)) { - throw new GradleException("Each registry module needs string gradle_path and source_path") +int expectedModuleCount = 19 +if (moduleRegistry.modules.size() != expectedModuleCount) { + throw new GradleException( + "Module registry must contain exactly ${expectedModuleCount} modules, " + + "but found ${moduleRegistry.modules.size()}: ${moduleRegistryFile}") +} + +Set moduleIds = new LinkedHashSet<>() +Set gradlePaths = new LinkedHashSet<>() +Set sourceDirectoryPaths = new LinkedHashSet<>() +String repositoryRootPrefix = repositoryRoot.path + File.separator + +List> validatedModules = moduleRegistry.modules.withIndex().collect { rawModule, index -> + if (!(rawModule instanceof Map)) { + throw new GradleException("Module registry entry ${index} must be a JSON object.") } + + Map module = rawModule as Map + ['id', 'gradle_path', 'source_path'].each { field -> + if (!(module[field] instanceof String) || (module[field] as String).isBlank()) { + throw new GradleException( + "Module registry entry ${index} needs a nonblank string '${field}'.") + } + } + if (!(module.allowed_dependencies instanceof List)) { + throw new GradleException( + "Module registry entry '${module.id}' needs an 'allowed_dependencies' list.") + } + + String id = module.id as String + String gradlePath = module.gradle_path as String + String sourcePath = module.source_path as String + List allowedDependencies = module.allowed_dependencies.withIndex().collect { + dependencyId, dependencyIndex -> + if (!(dependencyId instanceof String) || (dependencyId as String).isBlank()) { + throw new GradleException( + "Module registry entry '${id}' has a non-string or blank allowed dependency " + + "at index ${dependencyIndex}.") + } + dependencyId as String + } + + if (!moduleIds.add(id)) { + throw new GradleException("Module registry contains duplicate module id '${id}'.") + } + if (!gradlePath.startsWith(':')) { + throw new GradleException( + "Module registry entry '${id}' has Gradle path '${gradlePath}' that does not start with ':'.") + } + if (!gradlePaths.add(gradlePath)) { + throw new GradleException("Module registry contains duplicate Gradle path '${gradlePath}'.") + } + if (new File(sourcePath).isAbsolute()) { + throw new GradleException( + "Module registry entry '${id}' source path must be repository-root-relative: '${sourcePath}'.") + } + + File sourceDirectory = new File(repositoryRoot, sourcePath).canonicalFile + if (!sourceDirectory.path.startsWith(repositoryRootPrefix)) { + throw new GradleException( + "Module registry entry '${id}' source path escapes the repository root: '${sourcePath}'.") + } + if (!sourceDirectory.isDirectory()) { + throw new GradleException( + "Module registry entry '${id}' source path is not an existing directory: ${sourceDirectory}") + } + if (!sourceDirectoryPaths.add(sourceDirectory.path)) { + throw new GradleException( + "Module registry entry '${id}' resolves to duplicate or aliased canonical source directory: " + + "${sourceDirectory}") + } + + [ + id : id, + gradle_path : gradlePath, + source_directory : sourceDirectory, + allowed_dependencies: allowedDependencies + ] +} + +validatedModules.each { module -> + module.allowed_dependencies.each { dependencyId -> + if (dependencyId == module.id) { + throw new GradleException( + "Module registry entry '${module.id}' must not depend on itself.") + } + if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') { + throw new GradleException( + "Production module registry entry '${module.id}' must not allow a dependency on " + + "'sample-portfolio'.") + } + if (!moduleIds.contains(dependencyId)) { + throw new GradleException( + "Module registry entry '${module.id}' references unknown allowed dependency id " + + "'${dependencyId}'.") + } + } +} + +validatedModules.each { module -> include module.gradle_path - project(module.gradle_path).projectDir = new File(repositoryRoot, module.source_path) + project(module.gradle_path).projectDir = module.source_directory } diff --git a/src/shared-contract/CLAUDE.md b/src/shared-contract/CLAUDE.md index db3868d..a7330a6 100644 --- a/src/shared-contract/CLAUDE.md +++ b/src/shared-contract/CLAUDE.md @@ -4,9 +4,9 @@ - Module ID: `shared-contract` - Gradle path: `:shared-contract` -- Focused test: `./gradlew :shared-contract:test --console=plain` +- Focused test (derived from Gradle path): `./gradlew :shared-contract:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `.harness/project/modules.yaml`. +- Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.shared`. diff --git a/src/shared-contract/gradle.lockfile b/src/shared-contract/gradle.lockfile index d74791d..ff6d49b 100644 --- a/src/shared-contract/gradle.lockfile +++ b/src/shared-contract/gradle.lockfile @@ -1,23 +1,17 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -31,27 +25,16 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs @@ -60,28 +43,22 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath @@ -91,61 +68,15 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath empty=compileClasspath,runtimeClasspath